// 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: " 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} }) }