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 logFullscreen bool // Toggle fullscreen log view 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 } // 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, }) }) // Handle incoming audio - play through speakers m.client.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) { if m.audioPlayer != nil { m.audioPlayer.PlayPCM(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 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 } } // 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) { switch msg.String() { case "f": m.logFullscreen = !m.logFullscreen } 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 { m.inputText = m.inputText[:len(m.inputText)-1] } default: // Add character to input if len(msg.String()) == 1 { m.inputText += msg.String() } } return m, nil } // View renders the UI func (m *Model) View() string { if m.width == 0 { return "Loading..." } // Fullscreen Log Mode if m.logFullscreen { style := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("212")). // Active color Padding(0, 1). Width(m.width - 2). Height(m.height - 2) return style.Render(m.renderChat()) } // 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) // Chat panel chatContent := m.renderChat() if m.focus == FocusChat { chatPanelStyle = chatPanelStyle.BorderForeground(lipgloss.Color("212")) } chatPanel := chatPanelStyle.Render(chatContent) // Input inputContent := "> " + m.inputText if m.focus == FocusInput { inputStyle = inputStyle.BorderForeground(lipgloss.Color("212")) inputContent += "█" } input := inputStyle.Render(inputContent) // Footer help help := lipgloss.NewStyle().Faint(true).Render("↑↓ navigate │ Enter join │ Tab switch │ V talk │ M mute │ +/- vol │ q quit") // Combine panels panels := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, chatPanel) 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 lines = append(lines, lipgloss.NewStyle().Bold(true).Render("LOG")) lines = append(lines, "") if len(m.logMessages) == 0 { lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No logs yet...")) } else { // Limit to last N messages that fit in the panel maxLines := m.height - 10 if maxLines < 5 { maxLines = 5 } start := 0 if len(m.logMessages) > maxLines { start = len(m.logMessages) - maxLines } panelWidth := (m.width / 2) - 4 if m.logFullscreen { panelWidth = m.width - 6 } if panelWidth < 10 { panelWidth = 10 } for _, msg := range m.logMessages[start:] { // Truncate if too long to prevent wrapping breaking the layout displayMsg := msg if len(displayMsg) > panelWidth { displayMsg = displayMsg[:panelWidth-3] + "..." } // Replace newlines just in case displayMsg = strings.ReplaceAll(displayMsg, "\n", " ") lines = append(lines, lipgloss.NewStyle().Faint(true).Render(displayMsg)) } } return lipgloss.JoinVertical(lipgloss.Left, lines...) }