🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
// ABOUTME: Modal picker for audio output devices — shown over the main view
// ABOUTME: Save-and-restart model: selection writes audio_device to player.yaml
package ui
import (
"fmt"
"strings"
"github.com/Sendspin/sendspin-go/pkg/audio/output"
)
// pickerAction is what handlePickerKey tells the Model to do next. The
// picker itself never writes to disk; the Model layer owns side effects so
// the pure state machine here stays trivially testable.
type pickerAction int
const (
pickerNoop pickerAction = iota // key was consumed, nothing further to do
pickerClose // close without saving
pickerSave // save the currently-selected device and close
pickerPropagate // key was not handled by the picker; caller may handle it
)
type devicePickerState struct {
devices []output.PlaybackDevice
index int // currently-highlighted row
current string // device name currently in effect (for the "(current)" annotation)
loadErr error // non-nil when ListPlaybackDevices failed
saveEnabled bool // false when no config path is available — save is disabled, Esc still works
}
// newDevicePicker builds the state shown when the user first opens the
// modal. listFn is the enumerator — parameterised so tests can stub it.
func newDevicePicker(listFn func() ([]output.PlaybackDevice, error), current string, saveEnabled bool) devicePickerState {
devices, err := listFn()
s := devicePickerState{
current: current,
loadErr: err,
saveEnabled: saveEnabled,
}
if err != nil {
return s
}
s.devices = devices
// Seed selection: current device if matched; else default; else first.
for i, d := range devices {
if d.Name == current {
s.index = i
return s
}
}
for i, d := range devices {
if d.IsDefault {
s.index = i
return s
}
}
return s
}
// handlePickerKey is a pure state transition used by both production and
// tests. It returns the next state and an action the caller performs (close,
// save, etc.). The Model translates actions into side effects.
func (s devicePickerState) handlePickerKey(key string) (devicePickerState, pickerAction) {
switch key {
case "up":
if len(s.devices) > 0 && s.index > 0 {
s.index--
}
return s, pickerNoop
case "down":
if s.index < len(s.devices)-1 {
s.index++
}
return s, pickerNoop
case "esc", "q":
return s, pickerClose
case "enter":
if !s.saveEnabled || len(s.devices) == 0 {
return s, pickerNoop
}
return s, pickerSave
}
return s, pickerPropagate
}
// selected returns the device the user would save if they pressed Enter
// right now, or nil when the list is empty or failed to load.
func (s devicePickerState) selected() *output.PlaybackDevice {
if s.loadErr != nil || len(s.devices) == 0 {
return nil
}
if s.index < 0 || s.index >= len(s.devices) {
return nil
}
return &s.devices[s.index]
}
// renderPicker draws the modal. width is the TUI width; the picker sizes
// itself accordingly. If the list failed to load, the error is surfaced
// inside the modal so the user knows why the picker is empty.
func renderPicker(s devicePickerState, width int) string {
if width < 60 {
width = 60
}
inner := width - 4
var b strings.Builder
b.WriteString("\u250c\u2500 Select playback device " + repeatString("\u2500", width-26) + "\u2510\n")
if s.loadErr != nil {
line := fmt.Sprintf(" %s", truncateRunes(s.loadErr.Error(), inner-2))
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, line))
} else if len(s.devices) == 0 {
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, " (no playback devices found)"))
} else {
for i, d := range s.devices {
marker := " "
if i == s.index {
marker = ">"
}
annotations := []string{}
if d.IsDefault {
annotations = append(annotations, "default")
}
if d.Name == s.current {
annotations = append(annotations, "current")
}
name := d.Name
if len(annotations) > 0 {
name = fmt.Sprintf("%s (%s)", d.Name, strings.Join(annotations, ", "))
}
line := fmt.Sprintf("%s %s", marker, truncateRunes(name, inner-2))
// Highlight the selected row with reverse video so it reads even
// when the user's terminal doesn't do dim/bright styling well.
if i == s.index {
line = ansiReverse + padRight(line, inner) + ansiReset
b.WriteString(fmt.Sprintf("\u2502 %s \u2502\n", line))
} else {
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, line))
}
}
}
// Blank row then the keybinding hints.
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, ""))
hints := " " + hotkey(KeyUpDown, "move") + " " + hotkey(KeyEnter, "save") + " " + hotkey(KeyEsc, "cancel")
if !s.saveEnabled {
hints = " " + hotkey(KeyUpDown, "move") + " save unavailable (no writable config path) " + hotkey(KeyEsc, "cancel")
}
// Padding accounts for the fact that hints contains ANSI escape codes
// that don't count toward display width.
displayLen := displayLen(hints)
pad := inner - displayLen
if pad < 0 {
pad = 0
}
b.WriteString(fmt.Sprintf("\u2502 %s%s \u2502\n", hints, strings.Repeat(" ", pad)))
b.WriteString("\u2514" + repeatString("\u2500", width-2) + "\u2518\n")
return b.String()
}
// displayLen approximates how many terminal columns a string takes, ignoring
// ANSI escape sequences. Close enough for alignment padding; we're not
// supporting combining characters or East Asian width here.
func displayLen(s string) int {
count := 0
inEsc := false
for _, r := range s {
if r == '\x1b' {
inEsc = true
continue
}
if inEsc {
if r == 'm' {
inEsc = false
}
continue
}
count++
}
return count
}
// truncateRunes returns s clipped to maxCols runes, appending an ellipsis
// when truncation actually happens. Rune-safe variant of model.go's
// byte-based truncate — device names may contain non-ASCII characters.
func truncateRunes(s string, maxCols int) string {
if len([]rune(s)) <= maxCols {
return s
}
if maxCols <= 1 {
return "\u2026"
}
runes := []rune(s)
return string(runes[:maxCols-1]) + "\u2026"
}
// padRight pads s with spaces up to width display columns. Used for the
// reverse-video row so the highlight extends across the full row.
func padRight(s string, width int) string {
d := displayLen(s)
if d >= width {
return s
}
return s + strings.Repeat(" ", width-d)
}

View File

@@ -0,0 +1,274 @@
// ABOUTME: Tests for the device-picker state machine — pure functions, no TUI loop required
package ui
import (
"errors"
"strings"
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio/output"
)
func fixtureDevices() []output.PlaybackDevice {
return []output.PlaybackDevice{
{Name: "USB Audio"},
{Name: "Built-in Analog", IsDefault: true},
{Name: "HDMI"},
}
}
func listOK() ([]output.PlaybackDevice, error) {
return fixtureDevices(), nil
}
func TestNewDevicePicker_SeedsIndexFromCurrent(t *testing.T) {
s := newDevicePicker(listOK, "HDMI", true)
if s.index != 2 {
t.Errorf("index = %d, want 2 (HDMI)", s.index)
}
}
func TestNewDevicePicker_SeedsIndexFromDefaultWhenCurrentMissing(t *testing.T) {
s := newDevicePicker(listOK, "No such device", true)
if s.index != 1 {
t.Errorf("index = %d, want 1 (the IsDefault entry)", s.index)
}
}
func TestNewDevicePicker_SeedsZeroWhenNoDefaultAndNoCurrent(t *testing.T) {
listNoDefault := func() ([]output.PlaybackDevice, error) {
return []output.PlaybackDevice{
{Name: "One"},
{Name: "Two"},
}, nil
}
s := newDevicePicker(listNoDefault, "", true)
if s.index != 0 {
t.Errorf("index = %d, want 0", s.index)
}
}
func TestNewDevicePicker_PropagatesLoadError(t *testing.T) {
listErr := func() ([]output.PlaybackDevice, error) {
return nil, errors.New("no audio stack")
}
s := newDevicePicker(listErr, "", true)
if s.loadErr == nil {
t.Error("loadErr should be set when enumerator fails")
}
if s.selected() != nil {
t.Error("selected() should be nil when load failed")
}
}
func TestHandlePickerKey_UpDownClamp(t *testing.T) {
s := newDevicePicker(listOK, "USB Audio", true) // index 0
// Up at the top clamps.
s, act := s.handlePickerKey("up")
if s.index != 0 || act != pickerNoop {
t.Errorf("up at top: index=%d act=%d", s.index, act)
}
// Two downs.
s, _ = s.handlePickerKey("down")
s, _ = s.handlePickerKey("down")
if s.index != 2 {
t.Fatalf("after 2 downs, index = %d, want 2", s.index)
}
// Down at the bottom clamps.
s, _ = s.handlePickerKey("down")
if s.index != 2 {
t.Errorf("down at bottom: index = %d, want 2", s.index)
}
// One up.
s, _ = s.handlePickerKey("up")
if s.index != 1 {
t.Errorf("up from 2: index = %d, want 1", s.index)
}
}
func TestHandlePickerKey_EnterSavesWhenEnabled(t *testing.T) {
s := newDevicePicker(listOK, "", true)
_, act := s.handlePickerKey("enter")
if act != pickerSave {
t.Errorf("enter action = %d, want pickerSave", act)
}
}
func TestHandlePickerKey_EnterNoopWhenSaveDisabled(t *testing.T) {
s := newDevicePicker(listOK, "", false)
_, act := s.handlePickerKey("enter")
if act != pickerNoop {
t.Errorf("enter with save disabled action = %d, want pickerNoop", act)
}
}
func TestHandlePickerKey_EnterNoopOnEmptyList(t *testing.T) {
s := newDevicePicker(func() ([]output.PlaybackDevice, error) { return nil, nil }, "", true)
_, act := s.handlePickerKey("enter")
if act != pickerNoop {
t.Errorf("enter on empty action = %d, want pickerNoop", act)
}
}
func TestHandlePickerKey_EscAndQClose(t *testing.T) {
for _, key := range []string{"esc", "q"} {
s := newDevicePicker(listOK, "", true)
_, act := s.handlePickerKey(key)
if act != pickerClose {
t.Errorf("%q action = %d, want pickerClose", key, act)
}
}
}
func TestHandlePickerKey_UnknownPropagates(t *testing.T) {
s := newDevicePicker(listOK, "", true)
_, act := s.handlePickerKey("x")
if act != pickerPropagate {
t.Errorf("unknown key action = %d, want pickerPropagate", act)
}
}
func TestSelected_ReturnsCurrentRow(t *testing.T) {
s := newDevicePicker(listOK, "HDMI", true)
got := s.selected()
if got == nil {
t.Fatal("selected() = nil")
}
if got.Name != "HDMI" {
t.Errorf("selected().Name = %q, want HDMI", got.Name)
}
}
func TestRenderPicker_ShowsDefaultAndCurrentAnnotations(t *testing.T) {
s := newDevicePicker(listOK, "HDMI", true)
out := renderPicker(s, 80)
// Built-in Analog carries (default), HDMI carries (current).
if !strings.Contains(out, "Built-in Analog (default)") {
t.Errorf("expected '(default)' annotation; got:\n%s", out)
}
if !strings.Contains(out, "HDMI (current)") {
t.Errorf("expected '(current)' annotation on HDMI; got:\n%s", out)
}
}
func TestRenderPicker_AnnotatesBothWhenCurrentIsDefault(t *testing.T) {
s := newDevicePicker(listOK, "Built-in Analog", true)
out := renderPicker(s, 80)
if !strings.Contains(out, "Built-in Analog (default, current)") {
t.Errorf("expected combined annotation; got:\n%s", out)
}
}
func TestRenderPicker_EmptyAndErrorStates(t *testing.T) {
empty := newDevicePicker(func() ([]output.PlaybackDevice, error) { return nil, nil }, "", true)
emptyOut := renderPicker(empty, 80)
if !strings.Contains(emptyOut, "no playback devices found") {
t.Errorf("empty state did not render expected message; got:\n%s", emptyOut)
}
errState := newDevicePicker(func() ([]output.PlaybackDevice, error) {
return nil, errors.New("enumeration boom")
}, "", true)
errOut := renderPicker(errState, 80)
if !strings.Contains(errOut, "enumeration boom") {
t.Errorf("error state did not render error; got:\n%s", errOut)
}
}
func TestRenderPicker_SaveUnavailableHint(t *testing.T) {
s := newDevicePicker(listOK, "", false)
out := renderPicker(s, 80)
if !strings.Contains(out, "save unavailable") {
t.Errorf("expected 'save unavailable' hint when saveEnabled=false; got:\n%s", out)
}
}
// The two tests below integrate picker state with the Model's key dispatch
// to verify the end-to-end "press o → navigate → save" flow without
// standing up a full bubbletea runtime.
func TestModel_OKeyOpensPicker(t *testing.T) {
m := NewModel(Config{ConfigPath: "/tmp/test-player.yaml"})
m.listPlaybackDevices = listOK
m.width = 80
next, _ := m.handleKey(fakeKeyMsg("o"))
nm := next.(Model)
if nm.mode != modeDevicePicker {
t.Errorf("mode = %v, want modeDevicePicker", nm.mode)
}
if len(nm.picker.devices) != 3 {
t.Errorf("picker.devices len = %d, want 3", len(nm.picker.devices))
}
}
func TestModel_EnterInPickerSavesAndClosesModal(t *testing.T) {
var savedPath, savedKey, savedValue string
writer := func(path, key, value string) error {
savedPath, savedKey, savedValue = path, key, value
return nil
}
// Seed the current device so the picker opens highlighted on "USB Audio"
// (index 0). One "down" then lands on the IsDefault entry at index 1.
m := NewModel(Config{ConfigPath: "/tmp/test-player.yaml", AudioDevice: "USB Audio"})
m.listPlaybackDevices = listOK
m.writeConfigKey = writer
m.width = 80
// Open picker.
next, _ := m.handleKey(fakeKeyMsg("o"))
m = next.(Model)
// Navigate down to Built-in Analog (index 1).
next, _ = m.handleKey(fakeKeyMsg("down"))
m = next.(Model)
// Enter saves. Returns a Cmd (the 3s expire tick); we don't invoke it.
next, cmd := m.handleKey(fakeKeyMsg("enter"))
m = next.(Model)
if m.mode != modeNormal {
t.Errorf("mode after save = %v, want modeNormal", m.mode)
}
if savedPath != "/tmp/test-player.yaml" || savedKey != "audio_device" || savedValue != "Built-in Analog" {
t.Errorf("write recorded (%q, %q, %q); want (/tmp/test-player.yaml, audio_device, Built-in Analog)",
savedPath, savedKey, savedValue)
}
if m.transientMsg == "" {
t.Error("transient banner should be set after save")
}
if cmd == nil {
t.Error("save should schedule a transient-expire tick")
}
}
func TestModel_EnterWhenSelectionMatchesSkipsWrite(t *testing.T) {
writeCalled := false
writer := func(path, key, value string) error {
writeCalled = true
return nil
}
// Current is the HDMI entry; picker seeds selection there.
m := NewModel(Config{ConfigPath: "/tmp/test.yaml", AudioDevice: "HDMI"})
m.listPlaybackDevices = listOK
m.writeConfigKey = writer
m.width = 80
next, _ := m.handleKey(fakeKeyMsg("o"))
m = next.(Model)
next, _ = m.handleKey(fakeKeyMsg("enter"))
m = next.(Model)
if writeCalled {
t.Error("writeConfigKey should not be called when selection matches current")
}
if m.mode != modeNormal {
t.Errorf("mode = %v, want modeNormal", m.mode)
}
}

View File

@@ -0,0 +1,115 @@
// ABOUTME: Hotkey label rendering helper — marks the trigger character with reverse video
// ABOUTME: Used by all TUI labels that advertise a keybinding so the cue is uniform
package ui
import (
"strings"
"unicode"
)
// ANSI escape sequences for SGR reverse-video. Kept here rather than in a
// styling library because we only need two codes — pulling in lipgloss for
// this would be overkill.
const (
ansiReverse = "\x1b[7m"
ansiReset = "\x1b[0m"
)
// hotkey renders a human-readable label with its trigger key reverse-
// highlighted. The visual cue — a single inverted character embedded in the
// label — is uniform across every keybinding in the TUI so users learn the
// pattern once.
//
// Resolution rules:
// - Printable letter trigger with a case-insensitive match in the label:
// highlight the first occurrence in place.
// hotkey('o', "Output") -> "\x1b[7mO\x1b[0mutput"
// - Printable letter trigger NOT present in the label:
// fall back to a bracket prefix.
// hotkey('q', "Bye") -> "[\x1b[7mQ\x1b[0m] Bye"
// - Non-letter triggers (space, arrow sentinels, etc.) always use the
// bracket form with a friendly name for the key.
// hotkey(KeySpace, "Play/Pause") -> "[\x1b[7mSpace\x1b[0m] Play/Pause"
//
// Empty labels still render the bracket prefix so the binding is visible.
func hotkey(trigger rune, label string) string {
if name, ok := specialKeyName(trigger); ok {
return bracketPrefix(name, label)
}
if !unicode.IsLetter(trigger) && !unicode.IsDigit(trigger) {
// Punctuation or symbols — use bracket form with the literal rune.
return bracketPrefix(string(trigger), label)
}
if idx := firstCaseFoldIndex(label, trigger); idx >= 0 {
r, width := runeAt(label, idx)
return label[:idx] + ansiReverse + string(r) + ansiReset + label[idx+width:]
}
return bracketPrefix(strings.ToUpper(string(trigger)), label)
}
// Special-key sentinels. These aren't valid Unicode code points that could
// appear in a label, so overloading a rune-sized value is safe. Any value
// outside the printable range works; using specific private-use-area code
// points keeps them self-documenting in stack traces.
const (
KeySpace rune = '\uE000' // "Space"
KeyUp rune = '\uE001' // "↑"
KeyDown rune = '\uE002' // "↓"
KeyUpDown rune = '\uE003' // "↑↓"
KeyEnter rune = '\uE004' // "Enter"
KeyEsc rune = '\uE005' // "Esc"
)
func specialKeyName(r rune) (string, bool) {
switch r {
case KeySpace:
return "Space", true
case KeyUp:
return "\u2191", true
case KeyDown:
return "\u2193", true
case KeyUpDown:
return "\u2191\u2193", true
case KeyEnter:
return "Enter", true
case KeyEsc:
return "Esc", true
}
return "", false
}
// bracketPrefix renders "[<highlighted keyName>] label" with the key name
// reverse-highlighted. When label is empty, emits just the bracket (still
// useful so the binding is visible in compact hint rows).
func bracketPrefix(keyName, label string) string {
prefix := "[" + ansiReverse + keyName + ansiReset + "]"
if label == "" {
return prefix
}
return prefix + " " + label
}
// firstCaseFoldIndex returns the byte index of the first rune in s that
// case-folds equal to needle, or -1 if not found. Works for any letter —
// multi-byte ones included, though this codebase only uses ASCII triggers.
func firstCaseFoldIndex(s string, needle rune) int {
target := unicode.ToLower(needle)
for i, r := range s {
if unicode.ToLower(r) == target {
return i
}
}
return -1
}
// runeAt returns the rune at byte index i and its encoded byte width. Only
// called after firstCaseFoldIndex has confirmed a match, so the index is
// guaranteed to be on a rune boundary.
func runeAt(s string, i int) (rune, int) {
for j, r := range s {
if j == i {
return r, len(string(r))
}
}
return 0, 0
}

View File

@@ -0,0 +1,123 @@
// ABOUTME: Tests for the hotkey label helper — in-place reverse highlighting
package ui
import "testing"
func TestHotkey_LetterInLabel(t *testing.T) {
tests := []struct {
name string
trigger rune
label string
want string
}{
{
name: "first letter match",
trigger: 'o',
label: "Output",
want: ansiReverse + "O" + ansiReset + "utput",
},
{
name: "case-insensitive — lowercase trigger, uppercase in label",
trigger: 'q',
label: "Quit",
want: ansiReverse + "Q" + ansiReset + "uit",
},
{
name: "case-insensitive — uppercase trigger, lowercase in label",
trigger: 'E',
label: "enter",
want: ansiReverse + "e" + ansiReset + "nter",
},
{
name: "trigger matches a non-initial letter",
trigger: 'u',
label: "Quit",
want: "Q" + ansiReverse + "u" + ansiReset + "it",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hotkey(tt.trigger, tt.label)
if got != tt.want {
t.Errorf("hotkey(%q, %q) =\n %q\nwant\n %q", tt.trigger, tt.label, got, tt.want)
}
})
}
}
func TestHotkey_LetterNotInLabel(t *testing.T) {
got := hotkey('x', "Bye")
want := "[" + ansiReverse + "X" + ansiReset + "] Bye"
if got != want {
t.Errorf("missing-letter fallback =\n %q\nwant\n %q", got, want)
}
}
func TestHotkey_SpecialKeys(t *testing.T) {
tests := []struct {
name string
trigger rune
label string
want string
}{
{
name: "space",
trigger: KeySpace,
label: "Play/Pause",
want: "[" + ansiReverse + "Space" + ansiReset + "] Play/Pause",
},
{
name: "up-down combined",
trigger: KeyUpDown,
label: "Volume",
want: "[" + ansiReverse + "\u2191\u2193" + ansiReset + "] Volume",
},
{
name: "enter",
trigger: KeyEnter,
label: "Save",
want: "[" + ansiReverse + "Enter" + ansiReset + "] Save",
},
{
name: "esc",
trigger: KeyEsc,
label: "Cancel",
want: "[" + ansiReverse + "Esc" + ansiReset + "] Cancel",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hotkey(tt.trigger, tt.label)
if got != tt.want {
t.Errorf("hotkey(%q) =\n %q\nwant\n %q", tt.trigger, got, tt.want)
}
})
}
}
func TestHotkey_EmptyLabel(t *testing.T) {
// Letter with empty label uses the bracket fallback since nothing to match.
got := hotkey('q', "")
want := "[" + ansiReverse + "Q" + ansiReset + "]"
if got != want {
t.Errorf("empty label with letter =\n %q\nwant\n %q", got, want)
}
// Special key with empty label uses bracket without trailing space.
got = hotkey(KeyEsc, "")
want = "[" + ansiReverse + "Esc" + ansiReset + "]"
if got != want {
t.Errorf("empty label with special =\n %q\nwant\n %q", got, want)
}
}
func TestHotkey_Symbol(t *testing.T) {
// Symbols aren't letters; they use the bracket prefix with the literal rune.
got := hotkey('/', "Slash")
want := "[" + ansiReverse + "/" + ansiReset + "] Slash"
if got != want {
t.Errorf("symbol trigger =\n %q\nwant\n %q", got, want)
}
}

View File

@@ -0,0 +1,625 @@
// 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}
})
}

View File

@@ -0,0 +1,561 @@
// ABOUTME: Tests for TUI model and state management
// ABOUTME: Tests status updates, message handling, and state transitions
package ui
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/sync"
tea "github.com/charmbracelet/bubbletea"
)
func TestNewModel(t *testing.T) {
model := NewModel(Config{}) // VolumeControl and TransportControl are optional for testing
// Check initial state
if model.connected {
t.Error("expected connected to be false initially")
}
if model.volume != 100 {
t.Errorf("expected default volume 100, got %d", model.volume)
}
if model.muted {
t.Error("expected muted to be false initially")
}
if model.showDebug {
t.Error("expected showDebug to be false initially")
}
if model.playbackState != "idle" {
t.Errorf("expected playbackState 'idle', got '%s'", model.playbackState)
}
}
func TestStatusMsgConnected(t *testing.T) {
model := NewModel(Config{})
connected := true
msg := StatusMsg{
Connected: &connected,
ServerName: "test-server",
}
model.applyStatus(msg)
if !model.connected {
t.Error("expected connected to be true after status update")
}
if model.serverName != "test-server" {
t.Errorf("expected serverName 'test-server', got '%s'", model.serverName)
}
}
func TestStatusMsgDisconnected(t *testing.T) {
model := NewModel(Config{})
// First connect
connected := true
model.applyStatus(StatusMsg{Connected: &connected})
// Then disconnect
disconnected := false
model.applyStatus(StatusMsg{Connected: &disconnected})
if model.connected {
t.Error("expected connected to be false after disconnect")
}
}
func TestStatusMsgSyncStats(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
SyncRTT: 5000,
SyncQuality: sync.QualityGood,
}
model.applyStatus(msg)
if model.syncRTT != 5000 {
t.Errorf("expected syncRTT 5000, got %d", model.syncRTT)
}
if model.syncQuality != sync.QualityGood {
t.Errorf("expected QualityGood, got %v", model.syncQuality)
}
}
func TestStatusMsgStreamInfo(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Codec: "opus",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
model.applyStatus(msg)
if model.codec != "opus" {
t.Errorf("expected codec 'opus', got '%s'", model.codec)
}
if model.sampleRate != 48000 {
t.Errorf("expected sampleRate 48000, got %d", model.sampleRate)
}
if model.channels != 2 {
t.Errorf("expected channels 2, got %d", model.channels)
}
if model.bitDepth != 16 {
t.Errorf("expected bitDepth 16, got %d", model.bitDepth)
}
}
func TestStatusMsgMetadata(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Title: "Test Song",
Artist: "Test Artist",
Album: "Test Album",
}
model.applyStatus(msg)
if model.title != "Test Song" {
t.Errorf("expected title 'Test Song', got '%s'", model.title)
}
if model.artist != "Test Artist" {
t.Errorf("expected artist 'Test Artist', got '%s'", model.artist)
}
if model.album != "Test Album" {
t.Errorf("expected album 'Test Album', got '%s'", model.album)
}
}
func TestStatusMsgArtworkPath(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
ArtworkPath: "/tmp/artwork.jpg",
}
model.applyStatus(msg)
if model.artworkPath != "/tmp/artwork.jpg" {
t.Errorf("expected artworkPath '/tmp/artwork.jpg', got '%s'", model.artworkPath)
}
}
func TestStatusMsgVolume(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Volume: 75,
}
model.applyStatus(msg)
if model.volume != 75 {
t.Errorf("expected volume 75, got %d", model.volume)
}
}
func TestStatusMsgStats(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Received: 1000,
Played: 950,
Dropped: 50,
BufferDepth: 300,
}
model.applyStatus(msg)
if model.received != 1000 {
t.Errorf("expected received 1000, got %d", model.received)
}
if model.played != 950 {
t.Errorf("expected played 950, got %d", model.played)
}
if model.dropped != 50 {
t.Errorf("expected dropped 50, got %d", model.dropped)
}
if model.bufferDepth != 300 {
t.Errorf("expected bufferDepth 300, got %d", model.bufferDepth)
}
}
func TestStatusMsgRuntimeStats(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Goroutines: 42,
}
model.applyStatus(msg)
if model.goroutines != 42 {
t.Errorf("expected goroutines 42, got %d", model.goroutines)
}
}
func TestMultipleStatusUpdates(t *testing.T) {
model := NewModel(Config{})
// First update
connected := true
model.applyStatus(StatusMsg{
Connected: &connected,
Codec: "opus",
})
if model.codec != "opus" {
t.Error("first update failed")
}
// Second update (partial) - sampleRate requires Codec to be in same message
model.applyStatus(StatusMsg{
Codec: "opus",
SampleRate: 48000,
})
// Previous values should be retained
if model.codec != "opus" {
t.Error("previous codec value was lost")
}
if model.sampleRate != 48000 {
t.Error("new sampleRate not applied")
}
}
func TestStatusMsgZeroValues(t *testing.T) {
model := NewModel(Config{})
// Set some non-zero values first
model.applyStatus(StatusMsg{
Volume: 75,
Received: 100,
})
// Apply zero values - Volume should not update (0 is ignored), but stats should
model.applyStatus(StatusMsg{
Volume: 0,
Received: 0,
})
// Volume should NOT be updated to 0 (0 is special case)
if model.volume == 0 {
t.Error("volume should not be updated to 0")
}
// Stats should be updated (0 is valid)
if model.received != 0 {
t.Error("received stats should be updated to 0")
}
}
func TestTruncateFunction(t *testing.T) {
tests := []struct {
input string
maxLen int
expected string
}{
{"short", 10, "short"},
{"exactly ten c", 14, "exactly ten c"},
{"this is longer than allowed", 10, "this is..."},
{"this is longer than allowed", 15, "this is long..."}, // 15-3=12 chars + "..."
{"", 10, ""},
{"a", 10, "a"},
{"abc", 3, "abc"},
{"abcd", 4, "abcd"},
{"abcde", 4, "a..."},
}
for _, tt := range tests {
result := truncate(tt.input, tt.maxLen)
if result != tt.expected {
t.Errorf("truncate(%q, %d) = %q, expected %q",
tt.input, tt.maxLen, result, tt.expected)
}
}
}
func TestChannelNameFunction(t *testing.T) {
tests := []struct {
channels int
expected string
}{
{1, "Mono"},
{2, "Stereo"},
{3, "Stereo"}, // Function only distinguishes 1 vs other
{6, "Stereo"},
{0, "Stereo"},
}
for _, tt := range tests {
result := channelName(tt.channels)
if result != tt.expected {
t.Errorf("channelName(%d) = %q, expected %q",
tt.channels, result, tt.expected)
}
}
}
func TestSyncQualityDisplay(t *testing.T) {
model := NewModel(Config{})
// Test different quality levels
// Note: quality is only applied when SyncRTT is non-zero
qualities := []sync.Quality{
sync.QualityGood,
sync.QualityDegraded,
sync.QualityLost,
}
for _, q := range qualities {
model.applyStatus(StatusMsg{
SyncQuality: q,
SyncRTT: 1000, // Must include RTT for quality to be applied
})
if model.syncQuality != q {
t.Errorf("quality not updated to %v", q)
}
}
}
func TestMetadataClearing(t *testing.T) {
model := NewModel(Config{})
// Set metadata
model.applyStatus(StatusMsg{
Title: "Song",
Artist: "Artist",
Album: "Album",
})
// Clear metadata with empty strings
model.applyStatus(StatusMsg{
Title: "",
Artist: "",
Album: "",
})
// Empty strings should not clear (only non-empty values are applied)
if model.title != "Song" {
t.Error("title should not be cleared by empty string")
}
}
func TestPlaybackStateApplication(t *testing.T) {
model := NewModel(Config{})
// Initial state should be idle
if model.playbackState != "idle" {
t.Errorf("expected initial playbackState 'idle', got '%s'", model.playbackState)
}
// Apply playing state
model.applyStatus(StatusMsg{PlaybackState: "playing"})
if model.playbackState != "playing" {
t.Errorf("expected playbackState 'playing', got '%s'", model.playbackState)
}
// Apply paused state
model.applyStatus(StatusMsg{PlaybackState: "paused"})
if model.playbackState != "paused" {
t.Errorf("expected playbackState 'paused', got '%s'", model.playbackState)
}
// Empty string should not overwrite
model.applyStatus(StatusMsg{PlaybackState: ""})
if model.playbackState != "paused" {
t.Errorf("expected playbackState to remain 'paused', got '%s'", model.playbackState)
}
}
func TestTransportKeyToggle(t *testing.T) {
tc := NewTransportControl()
model := NewModel(Config{TransportCtrl: tc})
model.width = 80
model.height = 24
// Simulate space key
msg := fakeKeyMsg(" ")
model.handleKey(msg)
select {
case cmd := <-tc.Commands:
if cmd.Command != "toggle" {
t.Errorf("expected 'toggle' command, got '%s'", cmd.Command)
}
default:
t.Error("expected toggle command on transport channel")
}
}
func TestTransportKeyNext(t *testing.T) {
tc := NewTransportControl()
model := NewModel(Config{TransportCtrl: tc})
model.width = 80
model.height = 24
msg := fakeKeyMsg("n")
model.handleKey(msg)
select {
case cmd := <-tc.Commands:
if cmd.Command != "next" {
t.Errorf("expected 'next' command, got '%s'", cmd.Command)
}
default:
t.Error("expected next command on transport channel")
}
}
func TestTransportKeyPrevious(t *testing.T) {
tc := NewTransportControl()
model := NewModel(Config{TransportCtrl: tc})
model.width = 80
model.height = 24
msg := fakeKeyMsg("p")
model.handleKey(msg)
select {
case cmd := <-tc.Commands:
if cmd.Command != "previous" {
t.Errorf("expected 'previous' command, got '%s'", cmd.Command)
}
default:
t.Error("expected previous command on transport channel")
}
}
func TestTransportKeyReconnect(t *testing.T) {
tc := NewTransportControl()
model := NewModel(Config{TransportCtrl: tc})
model.width = 80
model.height = 24
msg := fakeKeyMsg("r")
model.handleKey(msg)
select {
case cmd := <-tc.Commands:
if cmd.Command != "reconnect" {
t.Errorf("expected 'reconnect' command, got '%s'", cmd.Command)
}
default:
t.Error("expected reconnect command on transport channel")
}
}
func TestTransportNilSafe(t *testing.T) {
// Transport keys should not panic when transportCtrl is nil
model := NewModel(Config{})
model.width = 80
model.height = 24
// These should not panic
model.handleKey(fakeKeyMsg(" "))
model.handleKey(fakeKeyMsg("n"))
model.handleKey(fakeKeyMsg("p"))
model.handleKey(fakeKeyMsg("r"))
}
func TestPlaybackStateDisplay(t *testing.T) {
tests := []struct {
state string
expected string
}{
{"playing", "\u25b6 Playing"},
{"paused", "\u23f8 Paused"},
{"stopped", "\u23f9 Stopped"},
{"idle", "\u25cf Idle"},
{"reconnecting", "\u21bb Reconnecting..."},
{"unknown", "\u25cf Idle"},
}
for _, tt := range tests {
result := playbackStateDisplay(tt.state)
if result != tt.expected {
t.Errorf("playbackStateDisplay(%q) = %q, expected %q",
tt.state, result, tt.expected)
}
}
}
func TestFormatSampleRate(t *testing.T) {
tests := []struct {
rate int
expected string
}{
{44100, "44.1kHz"},
{48000, "48kHz"},
{96000, "96kHz"},
{192000, "192kHz"},
{88200, "88.2kHz"},
{176400, "176.4kHz"},
{32000, "32kHz"},
{22050, "22.1kHz"},
}
for _, tt := range tests {
result := formatSampleRate(tt.rate)
if result != tt.expected {
t.Errorf("formatSampleRate(%d) = %q, expected %q",
tt.rate, result, tt.expected)
}
}
}
func TestFormatCodecDisplay(t *testing.T) {
tests := []struct {
codec string
sampleRate int
bitDepth int
expected string
}{
{"pcm", 192000, 24, "PCM 192kHz/24bit lossless"},
{"opus", 48000, 16, "OPUS 48kHz/16bit"},
{"flac", 48000, 24, "FLAC 48kHz/24bit lossless"},
{"flac", 44100, 16, "FLAC 44.1kHz/16bit lossless"},
{"aac", 44100, 16, "AAC 44.1kHz/16bit"},
}
for _, tt := range tests {
result := formatCodecDisplay(tt.codec, tt.sampleRate, tt.bitDepth)
if result != tt.expected {
t.Errorf("formatCodecDisplay(%q, %d, %d) = %q, expected %q",
tt.codec, tt.sampleRate, tt.bitDepth, result, tt.expected)
}
}
}
// fakeKeyMsg creates a tea.KeyMsg for testing.
func fakeKeyMsg(key string) tea.KeyMsg {
if key == " " {
return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(key)}
}
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
}
// NOTE: TestConcurrentStatusUpdates was removed because Bubble Tea
// guarantees sequential Update() calls - the Model is never accessed
// concurrently in real usage, so testing concurrent access is unrealistic.

View File

@@ -0,0 +1,65 @@
// ABOUTME: TUI initialization and control
// ABOUTME: Wraps bubbletea program for player UI
package ui
import (
tea "github.com/charmbracelet/bubbletea"
)
type VolumeControl struct {
Changes chan VolumeChangeMsg
Quit chan QuitMsg
}
func NewVolumeControl() *VolumeControl {
return &VolumeControl{
Changes: make(chan VolumeChangeMsg, 10),
Quit: make(chan QuitMsg, 1),
}
}
type TransportMsg struct {
Command string // "play", "pause", "toggle", "next", "previous", "reconnect"
}
type TransportControl struct {
Commands chan TransportMsg
}
func NewTransportControl() *TransportControl {
return &TransportControl{
Commands: make(chan TransportMsg, 10),
}
}
// Config bundles everything the TUI needs from main.go at construction.
// Extending by adding fields is intentionally cheap — the alternative was
// growing the Run() / NewModel() positional argument list every time a new
// TUI feature needs a dependency.
type Config struct {
VolumeCtrl *VolumeControl
TransportCtrl *TransportControl
// ConfigPath is where WriteStringKey persists settings like audio_device.
// Empty disables the Save action in modals that would write to disk.
ConfigPath string
// AudioDevice is the device currently driving playback, shown in the
// status line and used to seed the picker's highlighted row.
AudioDevice string
}
func NewModel(cfg Config) Model {
return Model{
volume: 100,
state: "idle",
playbackState: "idle",
volumeCtrl: cfg.VolumeCtrl,
transportCtrl: cfg.TransportCtrl,
configPath: cfg.ConfigPath,
audioDevice: cfg.AudioDevice,
}
}
func Run(cfg Config) (*tea.Program, error) {
p := tea.NewProgram(NewModel(cfg), tea.WithAltScreen())
return p, nil
}