Files
go-ts/cmd/tui/model.go
2026-01-16 22:41:26 +01:00

831 lines
19 KiB
Go

package main
import (
"fmt"
"sort"
"strings"
"time"
"go-ts/pkg/audio"
"go-ts/pkg/ts3client"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Focus indicates which panel has focus
type Focus int
const (
FocusChannels Focus = iota
FocusChat
FocusInput
)
// ChatMessage represents a message in the chat
type ChatMessage struct {
Time time.Time
Sender string
Content string
}
// ChannelNode represents a channel in the tree
type ChannelNode struct {
ID uint64
Name string
Users []UserNode
Expanded bool
Selected bool
}
// UserNode represents a user in a channel
type UserNode struct {
ID uint16
Nickname string
Talking bool
Muted bool
IsMe bool
}
// Model is the Bubble Tea model for our TUI
type Model struct {
// Connection
serverAddr string
nickname string
client *ts3client.Client
connected bool
connecting bool
serverName string
selfID uint16
ping int
// UI State
focus Focus
width, height int
channels []ChannelNode
selectedIdx int
chatMessages []ChatMessage
logMessages []string // Debug logs shown in chat panel
inputText string
inputActive bool
// Error handling
lastError string
// Voice activity
talkingClients map[uint16]bool // ClientID -> isTalking
// Audio state
audioPlayer *audio.Player
audioCapturer *audio.Capturer
playbackVol int // 0-100
micLevel int // 0-100 (current input level)
isMuted bool // Mic muted
isPTT bool // Push-to-talk active
// Program reference for sending messages from event handlers
program *tea.Program
showLog bool
}
// addLog adds a message to the log panel
func (m *Model) addLog(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
m.logMessages = append(m.logMessages, msg)
// Keep last 50 messages
if len(m.logMessages) > 50 {
m.logMessages = m.logMessages[1:]
}
}
// NewModel creates a new TUI model
func NewModel(serverAddr, nickname string) *Model {
return &Model{
serverAddr: serverAddr,
nickname: nickname,
focus: FocusChannels,
channels: []ChannelNode{},
chatMessages: []ChatMessage{},
logMessages: []string{"Starting..."},
talkingClients: make(map[uint16]bool),
playbackVol: 80, // Default 80% volume
}
}
// SetProgram sets the tea.Program reference for sending messages from event handlers
func (m *Model) SetProgram(p *tea.Program) {
m.program = p
}
// Init is called when the program starts
func (m *Model) Init() tea.Cmd {
return tea.Batch(
m.connectToServer(),
tea.SetWindowTitle("TeamSpeak TUI"),
)
}
// TS3Event wraps events from the TS3 client
type TS3Event struct {
Type string
Data map[string]any
}
// connectToServer initiates connection to TeamSpeak
func (m *Model) connectToServer() tea.Cmd {
return func() tea.Msg {
m.connecting = true
client := ts3client.New(m.serverAddr, ts3client.Config{
Nickname: m.nickname,
})
return ts3ClientMsg{client: client}
}
}
type ts3ClientMsg struct {
client *ts3client.Client
}
type connectedMsg struct {
clientID uint16
serverName string
}
type channelListMsg struct {
channels []*ts3client.Channel
}
type clientEnterMsg struct {
clientID uint16
nickname string
channelID uint64
}
type clientLeftMsg struct {
clientID uint16
}
type chatMsg struct {
senderID uint16
senderName string
message string
}
type talkingStatusMsg struct {
clientID uint16
talking bool
}
type errorMsg struct {
err string
}
type micLevelMsg int // Mic level 0-100
type tickMsg time.Time
// Update handles messages and user input
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case tea.KeyMsg:
return m.handleKeyPress(msg)
case ts3ClientMsg:
m.client = msg.client
m.connecting = true
// Register event handlers that need to send messages to the TUI
if m.program != nil {
m.client.On(ts3client.EventTalkingStatus, func(e *ts3client.TalkingStatusEvent) {
m.program.Send(talkingStatusMsg{
clientID: e.ClientID,
talking: e.Talking,
})
})
m.client.On(ts3client.EventMessage, func(e *ts3client.MessageEvent) {
m.program.Send(chatMsg{
senderID: e.SenderID,
senderName: e.SenderName,
message: e.Message,
})
})
// Handle incoming audio - play through speakers
m.client.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) {
if m.audioPlayer != nil {
m.audioPlayer.PlayPCM(e.SenderID, e.PCM)
}
})
}
// Initialize audio player
player, err := audio.NewPlayer()
if err != nil {
m.addLog("Audio player init failed: %v", err)
} else {
m.audioPlayer = player
m.audioPlayer.SetVolume(float32(m.playbackVol) / 100.0)
m.audioPlayer.Start()
m.addLog("Audio player initialized")
}
// Initialize audio capturer
capturer, err := audio.NewCapturer()
if err != nil {
m.addLog("Audio capturer init failed: %v", err)
} else {
m.audioCapturer = capturer
// Set callback to send audio to server when PTT is active
m.audioCapturer.SetCallback(func(samples []int16) {
if m.isPTT && m.client != nil && !m.isMuted {
m.client.SendAudio(samples)
}
// Update mic level for display
if m.program != nil {
m.program.Send(micLevelMsg(m.audioCapturer.GetLevel()))
}
})
m.addLog("Audio capturer initialized")
}
// Connect asynchronously
m.client.ConnectAsync()
// Set up a ticker to poll for state changes
return m, tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
return tickMsg(t)
})
case tickMsg:
// Poll client state
if m.client != nil {
// Check if connected
if !m.connected {
info := m.client.GetSelfInfo()
serverInfo := m.client.GetServerInfo()
m.addLog("Tick: selfInfo=%v, serverInfo=%v", info != nil, serverInfo != nil)
if info != nil && serverInfo != nil {
m.connected = true
m.selfID = info.ClientID
m.serverName = serverInfo.Name
m.addLog("Connected! ClientID=%d, Server=%s", m.selfID, m.serverName)
// Get channels
channels := m.client.GetChannels()
m.addLog("Got %d channels", len(channels))
m.updateChannelList(channels)
}
} else {
// Update channel list periodically
channels := m.client.GetChannels()
if len(channels) != len(m.channels) {
m.addLog("Channel update: got %d channels (had %d)", len(channels), len(m.channels))
}
m.updateChannelList(channels)
}
}
// Update mic level when PTT is active (multiply for better visibility)
if m.isPTT && m.audioCapturer != nil {
level := m.audioCapturer.GetLevel() * 4 // Boost for visibility
if level > 100 {
level = 100
}
m.micLevel = level
} else {
m.micLevel = 0 // Reset when not transmitting
}
// Continue ticking (100ms for responsive mic level)
return m, tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return tickMsg(t)
})
case connectedMsg:
m.connected = true
m.connecting = false
m.selfID = msg.clientID
m.serverName = msg.serverName
return m, nil
case channelListMsg:
m.updateChannelList(msg.channels)
return m, nil
case errorMsg:
m.lastError = msg.err
return m, nil
case talkingStatusMsg:
// Update talking status for client
if msg.talking {
m.talkingClients[msg.clientID] = true
} else {
delete(m.talkingClients, msg.clientID)
}
return m, nil
case chatMsg:
m.chatMessages = append(m.chatMessages, ChatMessage{
Time: time.Now(),
Sender: msg.senderName,
Content: msg.message,
})
// Keep last 100 messages
if len(m.chatMessages) > 100 {
m.chatMessages = m.chatMessages[1:]
}
return m, nil
case micLevelMsg:
// Update microphone level for display
m.micLevel = int(msg)
return m, nil
case logMsg:
// External log message
m.addLog("%s", string(msg))
return m, nil
}
return m, nil
}
type logMsg string
func (m *Model) updateChannelList(channels []*ts3client.Channel) {
// Sort channels by ID for stable ordering
sortedChannels := make([]*ts3client.Channel, len(channels))
copy(sortedChannels, channels)
sort.Slice(sortedChannels, func(i, j int) bool {
return sortedChannels[i].ID < sortedChannels[j].ID
})
m.channels = make([]ChannelNode, 0, len(sortedChannels))
for _, ch := range sortedChannels {
node := ChannelNode{
ID: ch.ID,
Name: ch.Name,
Users: []UserNode{},
Expanded: true,
}
// Get users in this channel
for _, cl := range m.client.GetClients() {
if cl.ChannelID == ch.ID {
node.Users = append(node.Users, UserNode{
ID: cl.ID,
Nickname: cl.Nickname,
IsMe: cl.ID == m.selfID,
Talking: m.talkingClients[cl.ID],
})
}
}
// Sort users by ID for stable ordering
sort.Slice(node.Users, func(i, j int) bool {
return node.Users[i].ID < node.Users[j].ID
})
m.channels = append(m.channels, node)
}
}
func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Global keys (work regardless of focus)
switch msg.String() {
case "ctrl+c", "q":
if m.client != nil {
m.client.Disconnect()
}
// Cleanup audio
if m.audioPlayer != nil {
m.audioPlayer.Close()
}
if m.audioCapturer != nil {
m.audioCapturer.Close()
}
return m, tea.Quit
case "tab":
// Cycle focus
m.focus = (m.focus + 1) % 3
return m, nil
case "m", "M":
// Toggle mute
m.isMuted = !m.isMuted
if m.audioPlayer != nil {
m.audioPlayer.SetMuted(m.isMuted)
}
return m, nil
case "+", "=":
// Increase volume
m.playbackVol += 10
if m.playbackVol > 100 {
m.playbackVol = 100
}
if m.audioPlayer != nil {
m.audioPlayer.SetVolume(float32(m.playbackVol) / 100.0)
}
return m, nil
case "-", "_":
// Decrease volume
m.playbackVol -= 10
if m.playbackVol < 0 {
m.playbackVol = 0
}
if m.audioPlayer != nil {
m.audioPlayer.SetVolume(float32(m.playbackVol) / 100.0)
}
return m, nil
case "v", "V":
// Toggle voice (PTT) - V to start/stop transmitting
if m.focus != FocusInput {
m.isPTT = !m.isPTT
if m.isPTT {
// Start capturing when PTT enabled
if m.audioCapturer != nil {
m.audioCapturer.Start()
}
m.addLog("🎤 Transmitting...")
} else {
// Stop capturing when PTT disabled
if m.audioCapturer != nil {
m.audioCapturer.Stop()
}
m.addLog("🎤 Stopped transmitting")
}
return m, nil
}
case "l", "L":
// Toggle Log/Chat view
if m.focus != FocusInput {
m.showLog = !m.showLog
return m, nil
}
}
// Focus-specific keys
switch m.focus {
case FocusChannels:
return m.handleChannelKeys(msg)
case FocusInput:
return m.handleInputKeys(msg)
case FocusChat:
return m.handleChatKeys(msg)
}
return m, nil
}
func (m *Model) handleChatKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *Model) handleChannelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.selectedIdx > 0 {
m.selectedIdx--
}
case "down", "j":
if m.selectedIdx < len(m.channels)-1 {
m.selectedIdx++
}
case "enter":
// Join selected channel
if m.selectedIdx < len(m.channels) && m.client != nil {
ch := m.channels[m.selectedIdx]
m.addLog("Attempting to join channel: %s (ID=%d)", ch.Name, ch.ID)
err := m.client.JoinChannel(ch.ID)
if err != nil {
m.addLog("Error joining channel: %v", err)
}
}
}
return m, nil
}
func (m *Model) handleInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
if m.inputText != "" && m.client != nil {
m.client.SendChannelMessage(m.inputText)
m.inputText = ""
}
case "esc":
m.focus = FocusChannels
m.inputText = ""
case "backspace":
if len(m.inputText) > 0 {
// Handle UTF-8 backspace properly
runes := []rune(m.inputText)
if len(runes) > 0 {
m.inputText = string(runes[:len(runes)-1])
}
}
default:
// Add character to input
// Allow Runes (including multi-byte like ñ) and Space
// Filter out special keys that might send description strings (like "alt+") by ensuring only 1 rune
if msg.Type == tea.KeyRunes {
runes := []rune(msg.String())
if len(runes) == 1 {
m.inputText += string(runes)
}
} else if msg.Type == tea.KeySpace {
m.inputText += " "
}
}
return m, nil
}
// View renders the UI
func (m *Model) View() string {
if m.width == 0 {
return "Loading..."
}
// Styles
// Layout: header(1) + panels + input(3) + help(1) = header + panels + 4
// panels should be height - 5 (1 for header, 3 for input with border, 1 for help)
panelHeight := m.height - 7
channelPanelStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(0, 1).
Width(m.width/3 - 2).
Height(panelHeight)
chatPanelStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(0, 1).
Width(m.width*2/3 - 2).
Height(panelHeight)
inputStyle := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(0, 1).
Width(m.width - 4)
// Header with status bar
header := m.renderStatusBar()
// Channel panel
channelContent := m.renderChannels()
if m.focus == FocusChannels {
channelPanelStyle = channelPanelStyle.BorderForeground(lipgloss.Color("212"))
}
channelPanel := channelPanelStyle.Render(channelContent)
// Right panel (Chat or Log)
var rightPanel string
if m.showLog {
// Log View
// Use chatPanelStyle default but add focus logic
logStyle := chatPanelStyle.Copy()
if m.focus == FocusChat {
logStyle = logStyle.BorderForeground(lipgloss.Color("212"))
}
// Limit to last N messages log to fit panel
// Panel height is m.height - 7, padding reduces it more
maxLines := m.height - 11
if maxLines < 1 {
maxLines = 1
}
start := 0
if len(m.logMessages) > maxLines {
start = len(m.logMessages) - maxLines
}
// Calculate available width for text
textWidth := (m.width * 2 / 3) - 6
if textWidth < 10 {
textWidth = 10
}
textStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
var lines []string
for _, msg := range m.logMessages[start:] {
// Sanitize: remove newlines/returns to prevent double spacing
msg = strings.ReplaceAll(msg, "\n", " ")
msg = strings.ReplaceAll(msg, "\r", "")
// Truncate simplisticly to prevent wrapping issues if too long
if len(msg) > textWidth {
msg = msg[:textWidth-3] + "..."
}
lines = append(lines, textStyle.Render(msg))
}
rightPanel = logStyle.Render(strings.Join(lines, "\n"))
} else {
// Chat View
chatContent := m.renderChat()
if m.focus == FocusChat {
chatPanelStyle = chatPanelStyle.BorderForeground(lipgloss.Color("212"))
}
rightPanel = chatPanelStyle.Render(chatContent)
}
// Input
inputContent := "> " + m.inputText
if m.focus == FocusInput {
inputStyle = inputStyle.BorderForeground(lipgloss.Color("212"))
inputContent += "█"
}
input := inputStyle.Render(inputContent)
// Footer help
logHelp := "L log"
if m.showLog {
logHelp = "L chat"
}
help := lipgloss.NewStyle().Faint(true).Render(fmt.Sprintf("↑↓ navigate │ Enter join │ Tab switch │ %s │ V talk │ M mute │ +/- vol │ q quit", logHelp))
// Combine panels
panels := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, rightPanel)
return lipgloss.JoinVertical(lipgloss.Left,
header,
panels,
input,
help,
)
}
// renderStatusBar renders the top status bar with ping, volume, and mic level
func (m *Model) renderStatusBar() string {
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Width(m.width)
if !m.connected {
return headerStyle.Render("[ Connecting... ]")
}
// Left: Server name (add padding manually)
leftPart := " " + m.serverName
// Center: Ping
ping := 0.0
if m.client != nil {
ping = m.client.GetPing()
}
centerPart := fmt.Sprintf("PING: %.0fms", ping)
// Right: Volume and Mic (add padding manually)
volBar := audio.LevelToBar(m.playbackVol, 6)
muteIcon := "VOL"
if m.isMuted {
muteIcon = "MUTE"
}
volPart := fmt.Sprintf("%s:%s%d%%", muteIcon, volBar, m.playbackVol)
micBar := audio.LevelToBar(m.micLevel, 6)
pttIcon := "MIC"
if m.isPTT {
pttIcon = "*TX*"
}
micPart := fmt.Sprintf("%s:%s", pttIcon, micBar)
rightPart := fmt.Sprintf("%s | %s ", volPart, micPart)
// Calculate spacing for centered ping
totalWidth := m.width
if totalWidth < 10 {
return ""
}
leftLen := lipgloss.Width(leftPart)
centerLen := lipgloss.Width(centerPart)
rightLen := lipgloss.Width(rightPart)
// Calculate spaces needed
leftSpace := (totalWidth - leftLen - centerLen - rightLen) / 2
rightSpace := totalWidth - leftLen - centerLen - rightLen - leftSpace
if leftSpace < 1 {
leftSpace = 1
}
if rightSpace < 1 {
rightSpace = 1
}
// Build the status line
spaces := func(n int) string {
if n <= 0 {
return ""
}
s := ""
for i := 0; i < n; i++ {
s += " "
}
return s
}
status := leftPart + spaces(leftSpace) + centerPart + spaces(rightSpace) + rightPart
return headerStyle.Render(status)
}
func (m *Model) renderChannels() string {
if len(m.channels) == 0 {
return "No channels..."
}
var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Render("CHANNELS"))
lines = append(lines, "")
for i, ch := range m.channels {
prefix := " "
if i == m.selectedIdx {
prefix = "► "
}
style := lipgloss.NewStyle()
if i == m.selectedIdx {
style = style.Bold(true).Foreground(lipgloss.Color("212"))
}
lines = append(lines, style.Render(prefix+ch.Name))
// Show users in channel
for _, user := range ch.Users {
userPrefix := " └─ "
userStyle := lipgloss.NewStyle().Faint(true)
// Talking users get bright green color and speaker icon
if user.Talking {
userStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("46")) // Bright green
userPrefix = " └─ 🔊 "
} else if user.IsMe {
userStyle = userStyle.Foreground(lipgloss.Color("82"))
userPrefix = " └─ ► "
}
lines = append(lines, userStyle.Render(userPrefix+user.Nickname))
}
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
func (m *Model) renderChat() string {
var lines []string
if len(m.chatMessages) == 0 {
lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No chat messages yet..."))
} else {
// Limit messages to fit panel
// Panel height is roughly m.height - 7
maxLines := m.height - 9
if maxLines < 1 {
maxLines = 1
}
start := 0
if len(m.chatMessages) > maxLines {
start = len(m.chatMessages) - maxLines
}
for _, msg := range m.chatMessages[start:] {
prefix := msg.Time.Format("15:04") + " " + msg.Sender + ": "
content := msg.Content
// Simple wrapping logic could go here, but for now simple truncation/display
line := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(prefix) + content
lines = append(lines, line)
}
}
// Fill remaining lines with empty space to maintain stable layout
for len(lines) < m.height-9 {
lines = append(lines, "")
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}