Files

626 lines
16 KiB
Go

// ABOUTME: Bubbletea model for player TUI
// ABOUTME: Defines application state and update logic
package ui
import (
"fmt"
"strings"
"time"
"github.com/Sendspin/sendspin-go/pkg/audio/output"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
"github.com/Sendspin/sendspin-go/pkg/sync"
tea "github.com/charmbracelet/bubbletea"
)
type uiMode int
const (
modeNormal uiMode = iota
modeDevicePicker
)
// transientExpireMsg is fired after a transient banner's lifetime to clear
// it. Bubbletea's scheduler guarantees at-most-once delivery.
type transientExpireMsg struct{ nonce uint64 }
// Model represents the TUI state
type Model struct {
// Connection
connected bool
serverName string
// Sync
syncRTT int64
syncQuality sync.Quality
// Stream
codec string
sampleRate int
channels int
bitDepth int
// Metadata
title string
artist string
album string
artworkPath string
// Playback
state string
playbackState string // "playing", "paused", "stopped", "idle", "reconnecting"
volume int
muted bool
// Stats
received int64
played int64
dropped int64
bufferDepth int
// Debug
showDebug bool
goroutines int
// Dimensions
width int
height int
// Controls
volumeCtrl *VolumeControl
transportCtrl *TransportControl
// Audio device selection
audioDevice string // current device driving playback; shown in status row
configPath string // where picker saves audio_device; empty disables save
// Modal state
mode uiMode
picker devicePickerState
// Transient banner shown in the controls area (e.g. "Saved. Restart to apply.")
transientMsg string
transientNonce uint64 // incremented per banner so stale expire ticks are ignored
// listPlaybackDevices is indirected so tests can stub it.
listPlaybackDevices func() ([]output.PlaybackDevice, error)
// writeConfigKey persists a key to the config file. Indirected for tests.
writeConfigKey func(path, key, value string) error
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case StatusMsg:
m.applyStatus(msg)
case transientExpireMsg:
if msg.nonce == m.transientNonce {
m.transientMsg = ""
}
}
return m, nil
}
func (m Model) View() string {
if m.width == 0 {
return "Loading..."
}
if m.mode == modeDevicePicker {
// Modal takes over the viewport; the normal view is suspended. A
// minimal header keeps context ("which player am I configuring?").
return m.renderHeader() + renderPicker(m.picker, m.width)
}
s := ""
s += m.renderHeader()
s += m.renderStreamInfo()
s += m.renderControls()
s += m.renderStats()
if m.showDebug {
s += m.renderDebug()
}
s += m.renderHelp()
return s
}
func (m Model) renderHeader() string {
connStatus := "Disconnected"
if m.connected {
connStatus = fmt.Sprintf("Connected to %s", m.serverName)
}
stateDisplay := playbackStateDisplay(m.playbackState)
syncDisplay := "Sync: \u2717 Lost"
switch m.syncQuality {
case sync.QualityGood:
syncDisplay = fmt.Sprintf("Sync: \u2713 Good (RTT: %.1fms)", float64(m.syncRTT)/1000.0)
case sync.QualityDegraded:
syncDisplay = "Sync: \u26a0 Degraded"
}
// Use terminal width for responsive layout
width := m.width
if width < 60 {
width = 60 // Minimum width
}
innerWidth := width - 4 // Account for borders
titleWidth := width - 20 // Space for "┌─ Sendspin Player " prefix
title := "\u250c\u2500 Sendspin Player " + repeatString("\u2500", titleWidth) + "\u2510\n"
statusLine := fmt.Sprintf("\u2502 Status: %-*s \u2502\n", innerWidth-9, truncate(connStatus, innerWidth-9))
// State + Sync on same line
statePrefix := fmt.Sprintf("State: %s", stateDisplay)
// Calculate available space: innerWidth minus "State: <state>" minus spacing minus sync display
stateSyncLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth,
fmt.Sprintf("%-30s %s", statePrefix, syncDisplay))
separator := "\u251c" + repeatString("\u2500", width-2) + "\u2524\n"
return title + statusLine + stateSyncLine + separator
}
func (m Model) renderStreamInfo() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
if !m.connected || m.codec == "" {
return fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "No stream")
}
s := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "Now Playing:")
if m.title != "" {
metaWidth := innerWidth - 10 // Account for " Track: " prefix
s += fmt.Sprintf("\u2502 Track: %-*s \u2502\n", innerWidth-10, truncate(m.title, metaWidth))
s += fmt.Sprintf("\u2502 Artist: %-*s \u2502\n", innerWidth-10, truncate(m.artist, metaWidth))
s += fmt.Sprintf("\u2502 Album: %-*s \u2502\n", innerWidth-10, truncate(m.album, metaWidth))
if m.artworkPath != "" {
s += fmt.Sprintf("\u2502 Art: %-*s \u2502\n", innerWidth-10, truncate(m.artworkPath, metaWidth))
}
} else {
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth-3, "(No metadata)")
}
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
formatStr := formatCodecDisplay(m.codec, m.sampleRate, m.bitDepth)
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, formatStr)
return s
}
func (m Model) renderControls() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
muteIcon := ""
if m.muted {
muteIcon = " \U0001f507"
}
volumeBar := renderBar(m.volume, 100, 20)
s := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
volumeStr := fmt.Sprintf("Volume: [%s] %d%%%s", volumeBar, m.volume, muteIcon)
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, volumeStr)
bufferStr := fmt.Sprintf("Buffer: %dms", m.bufferDepth)
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, bufferStr)
// Output device status row. Uses the hotkey helper so the 'O' rendering
// stays in sync with the help line that advertises the trigger.
outputName := m.audioDevice
if outputName == "" {
outputName = "(miniaudio default)"
}
outputLine := hotkey('o', "Output: "+truncate(outputName, innerWidth-10))
s += renderLineWithANSI(innerWidth, outputLine)
if m.transientMsg != "" {
s += renderLineWithANSI(innerWidth, " "+m.transientMsg)
}
return s
}
// renderLineWithANSI emits a bordered row whose contents contain ANSI
// escape sequences. fmt's padding verbs count those escape bytes toward
// width, leading to short rows; this helper pads using visible-column
// count instead so every border stays aligned.
func renderLineWithANSI(innerWidth int, content string) string {
pad := innerWidth - displayLen(content)
if pad < 0 {
pad = 0
}
return fmt.Sprintf("\u2502 %s%s \u2502\n", content, strings.Repeat(" ", pad))
}
func (m Model) renderStats() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
separator := "\u251c" + repeatString("\u2500", width-2) + "\u2524\n"
statsStr := fmt.Sprintf("Stats: RX: %d Played: %d Dropped: %d", m.received, m.played, m.dropped)
statsLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, statsStr)
emptyLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
return separator + statsLine + emptyLine
}
func (m Model) renderHelp() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
// Uniform hotkey rendering — every trigger character is reverse-
// highlighted inline via hotkey(). Separators are two spaces; we rely
// on renderLineWithANSI for correct padding because hotkey() emits
// escape codes that fmt would miscount.
parts := []string{
hotkey(KeySpace, "Play/Pause"),
hotkey('n', "Next"),
hotkey('p', "Prev"),
hotkey(KeyUpDown, "Volume"),
hotkey('m', "Mute"),
hotkey('o', "Output"),
hotkey('q', "Quit"),
}
helpStr := strings.Join(parts, " ")
helpLine := renderLineWithANSI(innerWidth, helpStr)
bottom := "\u2514" + repeatString("\u2500", width-2) + "\u2518\n"
return helpLine + bottom
}
func (m Model) renderDebug() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
debugTitle := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "DEBUG:")
goroutineStr := fmt.Sprintf(" Goroutines: %d", m.goroutines)
goroutineLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, goroutineStr)
playbackStr := fmt.Sprintf(" Playback: %s", m.playbackState)
playbackLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, playbackStr)
codecStr := fmt.Sprintf(" Preferred codec: %s", m.codec)
codecLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, codecStr)
return debugTitle + goroutineLine + playbackLine + codecLine
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.mode == modeDevicePicker {
return m.handlePickerMode(msg)
}
switch msg.String() {
case "o":
return m.openDevicePicker()
case "q", "ctrl+c":
if m.volumeCtrl != nil {
select {
case m.volumeCtrl.Quit <- QuitMsg{}:
default:
// Channel full, skip
}
}
return m, tea.Quit
case "up":
if m.volume < 100 {
m.volume += 5
if m.volume > 100 {
m.volume = 100
}
if m.volumeCtrl != nil {
select {
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
default:
// Channel full, skip
}
}
}
case "down":
if m.volume > 0 {
m.volume -= 5
if m.volume < 0 {
m.volume = 0
}
if m.volumeCtrl != nil {
select {
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
default:
// Channel full, skip
}
}
}
case "m":
m.muted = !m.muted
// Send volume change to player via channel
if m.volumeCtrl != nil {
select {
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
default:
// Channel full, skip
}
}
case " ":
if m.transportCtrl != nil {
select {
case m.transportCtrl.Commands <- TransportMsg{Command: "toggle"}:
default:
}
}
case "n":
if m.transportCtrl != nil {
select {
case m.transportCtrl.Commands <- TransportMsg{Command: "next"}:
default:
}
}
case "p":
if m.transportCtrl != nil {
select {
case m.transportCtrl.Commands <- TransportMsg{Command: "previous"}:
default:
}
}
case "r":
if m.transportCtrl != nil {
select {
case m.transportCtrl.Commands <- TransportMsg{Command: "reconnect"}:
default:
}
}
case "d":
m.showDebug = !m.showDebug
}
return m, nil
}
func (m *Model) applyStatus(msg StatusMsg) {
if msg.Connected != nil {
m.connected = *msg.Connected
}
if msg.ServerName != "" {
m.serverName = msg.ServerName
}
if msg.PlaybackState != "" {
m.playbackState = msg.PlaybackState
}
// Sync stats are always applied when sent
if msg.SyncRTT != 0 {
m.syncRTT = msg.SyncRTT
m.syncQuality = msg.SyncQuality
}
if msg.Codec != "" {
m.codec = msg.Codec
m.sampleRate = msg.SampleRate
m.channels = msg.Channels
m.bitDepth = msg.BitDepth
}
if msg.Title != "" {
m.title = msg.Title
m.artist = msg.Artist
m.album = msg.Album
}
if msg.ArtworkPath != "" {
m.artworkPath = msg.ArtworkPath
}
// Volume is always applied when explicitly sent (can be 0 for silent)
// We rely on caller not sending Volume=0 in messages unless it's intentional
if msg.Volume != 0 {
m.volume = msg.Volume
}
// Always apply stats - they can legitimately be zero
m.received = msg.Received
m.played = msg.Played
m.dropped = msg.Dropped
m.bufferDepth = msg.BufferDepth
m.goroutines = msg.Goroutines
}
type StatusMsg struct {
Connected *bool
ServerName string
PlaybackState string
SyncRTT int64
SyncQuality sync.Quality
Codec string
SampleRate int
Channels int
BitDepth int
Title string
Artist string
Album string
ArtworkPath string
Volume int
Received int64
Played int64
Dropped int64
BufferDepth int
Goroutines int
}
type VolumeChangeMsg struct {
Volume int
Muted bool
}
type QuitMsg struct{}
func renderBar(value, max, width int) string {
filled := (value * width) / max
return strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", width-filled)
}
func truncate(s string, length int) string {
if len(s) <= length {
return s
}
return s[:length-3] + "..."
}
func channelName(channels int) string {
if channels == 1 {
return "Mono"
}
return "Stereo"
}
func repeatString(s string, count int) string {
if count <= 0 {
return ""
}
return strings.Repeat(s, count)
}
// playbackStateDisplay returns a display string with icon for the given playback state.
func playbackStateDisplay(state string) string {
switch state {
case "playing":
return "\u25b6 Playing"
case "paused":
return "\u23f8 Paused"
case "stopped":
return "\u23f9 Stopped"
case "idle":
return "\u25cf Idle"
case "reconnecting":
return "\u21bb Reconnecting..."
default:
return "\u25cf Idle"
}
}
// formatSampleRate returns a human-friendly sample rate string.
func formatSampleRate(rate int) string {
switch rate {
case 44100:
return "44.1kHz"
case 48000:
return "48kHz"
case 88200:
return "88.2kHz"
case 96000:
return "96kHz"
case 176400:
return "176.4kHz"
case 192000:
return "192kHz"
default:
if rate%1000 == 0 {
return fmt.Sprintf("%dkHz", rate/1000)
}
return fmt.Sprintf("%.1fkHz", float64(rate)/1000.0)
}
}
// formatCodecDisplay returns a rich codec description string.
func formatCodecDisplay(codec string, sampleRate, bitDepth int) string {
rate := formatSampleRate(sampleRate)
upper := strings.ToUpper(codec)
switch strings.ToLower(codec) {
case "pcm":
return fmt.Sprintf("%s %s/%dbit lossless", upper, rate, bitDepth)
case "flac":
return fmt.Sprintf("%s %s/%dbit lossless", upper, rate, bitDepth)
case "opus":
return fmt.Sprintf("%s %s/%dbit", upper, rate, bitDepth)
default:
return fmt.Sprintf("%s %s/%dbit", upper, rate, bitDepth)
}
}
// openDevicePicker transitions the Model into modeDevicePicker, enumerating
// available devices via the injected lister (defaulting to the real
// output.ListPlaybackDevices). The picker's own state tracks enumeration
// errors, so we never fail to enter the modal — the user always gets to
// see *something*, even if it's "couldn't enumerate devices".
func (m Model) openDevicePicker() (tea.Model, tea.Cmd) {
lister := m.listPlaybackDevices
if lister == nil {
lister = output.ListPlaybackDevices
}
saveEnabled := m.configPath != ""
m.picker = newDevicePicker(lister, m.audioDevice, saveEnabled)
m.mode = modeDevicePicker
return m, nil
}
// handlePickerMode dispatches a key to the picker's pure state machine and
// executes the requested action. Save writes audio_device to the config
// file and shows a transient "Saved. Restart to apply." banner for 3s.
func (m Model) handlePickerMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
next, action := m.picker.handlePickerKey(msg.String())
m.picker = next
switch action {
case pickerNoop:
return m, nil
case pickerClose, pickerPropagate:
m.mode = modeNormal
return m, nil
case pickerSave:
chosen := m.picker.selected()
if chosen == nil {
m.mode = modeNormal
return m, nil
}
writer := m.writeConfigKey
if writer == nil {
writer = sendspin.WriteStringKey
}
if chosen.Name != m.audioDevice {
// No-op when the chosen value already matches what's saved —
// matches the client_id write-back behavior and avoids
// churning user-edited config files for no reason.
if err := writer(m.configPath, "audio_device", chosen.Name); err != nil {
m.mode = modeNormal
return m.withTransient(fmt.Sprintf("Save failed: %v", err))
}
}
m.mode = modeNormal
return m.withTransient(fmt.Sprintf("Saved audio_device=%q. Restart to apply.", chosen.Name))
}
return m, nil
}
// withTransient schedules msg to be shown in the status row for ~3 seconds.
// The nonce guards against a later expire firing for a previously-shown
// banner that was already overwritten by a newer one.
func (m Model) withTransient(msg string) (tea.Model, tea.Cmd) {
m.transientNonce++
m.transientMsg = msg
nonce := m.transientNonce
return m, tea.Tick(3*time.Second, func(time.Time) tea.Msg {
return transientExpireMsg{nonce: nonce}
})
}