package main import ( "fmt" "regexp" "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 FocusUserView FocusAbout ) // ListItem represents an item in the navigation tree (Channel or User) type ListItem struct { IsUser bool Channel *ChannelNode User *UserNode } func (l ListItem) IsSpacer() bool { if l.IsUser || l.Channel == nil { return false } // TeamSpeak spacers usually look like [spacer0], [*spacer1], etc. return strings.Contains(strings.ToLower(l.Channel.Name), "[spacer") } // 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 Depth int } // 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 lastFocus Focus // To return from About view width, height int channels []ChannelNode items []ListItem // Flattened list for navigation 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 (Manual TX) vadEnabled bool // Voice Activation Detection active vadThreshold int // 0-100 threshold for VAD vadLastTriggered time.Time // Last time VAD threshold was exceeded // Popup State showPokePopup bool pokePopupSender string pokePopupMessage string // Program reference for sending messages from event handlers program *tea.Program showLog bool // User View showUserView bool viewUser *UserNode pokeID uint16 // Target ID for pending poke } // 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 vadEnabled: true, vadThreshold: 50, } } // 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 clientMovedMsg struct { clientID uint16 channelID uint64 } type pokeMsg struct { senderName string message string } 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) } }) m.client.On(ts3client.EventClientMoved, func(e *ts3client.ClientMovedEvent) { m.program.Send(clientMovedMsg{ clientID: e.ClientID, channelID: e.ChannelID, }) }) m.client.On(ts3client.EventPoke, func(e *ts3client.PokeEvent) { m.program.Send(pokeMsg{ senderName: e.SenderName, message: e.Message, }) }) } // 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 // Set callback to send audio to server when PTT is active m.audioCapturer.SetCallback(func(samples []int16) { // Calculate level of this frame for VAD decision // Note: GetLevel() is smoothed, we might want instant frame level for VAD trigger? // But pkg/audio/level.go is efficient. Let's re-calculate for precision. level := audio.CalculateRMSLevel(samples) // Determine if we should transmit shouldTransmit := false // Manual PTT (Locked on with 'v') if m.isPTT { shouldTransmit = true } // VAD Logic if m.vadEnabled && !m.isMuted { if level > m.vadThreshold { shouldTransmit = true m.vadLastTriggered = time.Now() } else if !m.vadLastTriggered.IsZero() && time.Since(m.vadLastTriggered) < 1*time.Second { // Hold VAD open for 1 second (decay) shouldTransmit = true } } // Allow transmission if forced or VAD triggered if shouldTransmit && m.client != nil && !m.isMuted { m.client.SendAudio(samples) } // Update mic level for display (use the calculated level) if m.program != nil { // Use goroutine to prevent blocking the capture loop if the UI is busy (e.g. shutting down) go m.program.Send(micLevelMsg(level)) } }) m.addLog("Audio capturer initialized") // Start capture immediately if VAD is enabled or PTT is active if m.vadEnabled || m.isPTT { if err := m.audioCapturer.Start(); err != nil { m.addLog("Error starting audio capture: %v", err) } else { m.addLog("Audio capture started (VAD/PTT active)") } } } // 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) } } // Legacy mic level handling removed to support VAD event-driven updates // 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 clientMovedMsg: if msg.clientID == m.selfID { chName := "Unknown" if ch := m.client.GetChannel(msg.channelID); ch != nil { chName = ch.Name } m.chatMessages = append(m.chatMessages, ChatMessage{ Time: time.Now(), Sender: "SYSTEM", Content: fmt.Sprintf("You moved to channel: %s", chName), }) } return m, nil case pokeMsg: // Append to chat as well m.chatMessages = append(m.chatMessages, ChatMessage{ Time: time.Now(), Sender: "POKE", Content: fmt.Sprintf("[%s]: %s", msg.senderName, msg.message), }) m.addLog("Received poke from %s: %s", msg.senderName, msg.message) // Trigger Popup m.showPokePopup = true m.pokePopupSender = msg.senderName m.pokePopupMessage = msg.message 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) { // Build adjacency map: ParentID -> PreviousID(Order) -> Channel levelMap := make(map[uint64]map[uint64]*ts3client.Channel) for _, ch := range channels { if levelMap[ch.ParentID] == nil { levelMap[ch.ParentID] = make(map[uint64]*ts3client.Channel) } levelMap[ch.ParentID][ch.Order] = ch } var sortedNodes []ChannelNode // Recursive function to flatten the tree in order var visit func(parentID uint64, depth int) visit = func(parentID uint64, depth int) { prevID := uint64(0) for { ch, ok := levelMap[parentID][prevID] if !ok { break // End of list for this parent } // Create node node := ChannelNode{ ID: ch.ID, Name: ch.Name, Users: []UserNode{}, Expanded: true, Depth: depth, } // 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 }) sortedNodes = append(sortedNodes, node) // Recursively visit children visit(ch.ID, depth+1) // Move to next sibling prevID = ch.ID } } // Start from root (ParentID = 0) visit(0, 0) // In case there are orphaned channels (shouldn't happen on standard server), standard logic ignores them. // We'll stick to valid tree. m.channels = sortedNodes // Build flattened list for navigation m.items = make([]ListItem, 0) for i := range m.channels { ch := &m.channels[i] m.items = append(m.items, ListItem{IsUser: false, Channel: ch}) if ch.Expanded { for j := range ch.Users { u := &ch.Users[j] m.items = append(m.items, ListItem{IsUser: true, User: u, Channel: ch}) } } } // Ensure selectedIdx is valid (not on a spacer) if len(m.items) > 0 { if m.selectedIdx >= len(m.items) { m.selectedIdx = len(m.items) - 1 } // If current is a spacer, find next valid one if m.items[m.selectedIdx].IsSpacer() { found := false // Try going down for i := m.selectedIdx; i < len(m.items); i++ { if !m.items[i].IsSpacer() { m.selectedIdx = i found = true break } } // If not found, try going up if !found { for i := m.selectedIdx; i >= 0; i-- { if !m.items[i].IsSpacer() { m.selectedIdx = i break } } } } } } func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() // Global Key Handling for Popup if m.showPokePopup { if key == "esc" || key == "enter" || key == "q" { m.showPokePopup = false } return m, nil } // 1. Absolute Globals (Always active) switch key { case "ctrl+c": 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) % 4 // FocusChannels, FocusChat, FocusInput, FocusUserView return m, nil case "f1": if m.focus == FocusAbout { m.focus = m.lastFocus } else { m.lastFocus = m.focus m.focus = FocusAbout } return m, nil } // 2. Input Focus Priority // If typing, ignore all other shortcuts except the absolute globals above if m.focus == FocusInput { return m.handleInputKeys(msg) } // 3. Global Shortcuts (Only when NOT in Input or About) if m.focus != FocusInput && m.focus != FocusUserView && m.focus != FocusAbout { switch msg.String() { case "q": // Quit (same as ctrl+c) if m.client != nil { m.client.Disconnect() } if m.audioPlayer != nil { m.audioPlayer.Close() } if m.audioCapturer != nil { m.audioCapturer.Close() } return m, tea.Quit 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 "ctrl+up", "ctrl+right": // Increase VAD threshold m.vadThreshold += 5 if m.vadThreshold > 100 { m.vadThreshold = 100 } m.addLog("VAD Threshold: %d", m.vadThreshold) return m, nil case "ctrl+down", "ctrl+left": // Decrease VAD threshold m.vadThreshold -= 5 if m.vadThreshold < 0 { m.vadThreshold = 0 } m.addLog("VAD Threshold: %d", m.vadThreshold) return m, nil case "g", "G": // Toggle VAD (Gate) m.vadEnabled = !m.vadEnabled state := "OFF" if m.vadEnabled { state = "ON" } m.addLog("Voice Activation (Gate): %s", state) return m, nil case "v", "V": // Toggle voice (PTT) m.isPTT = !m.isPTT if m.isPTT { if m.audioCapturer != nil { m.audioCapturer.Start() } m.addLog("🎤 Transmitting...") } else { if m.audioCapturer != nil { m.audioCapturer.Stop() } m.addLog("🎤 Stopped transmitting") } return m, nil case "l", "L": // Toggle Log/Chat view 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) case FocusUserView: return m.handleUserViewKeys(msg) case FocusAbout: return m.handleAboutViewKeys(msg) } return m, nil } func (m *Model) handleChatKeys(_ tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } func (m *Model) handleAboutViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.String() == "esc" || msg.String() == "f1" { m.focus = m.lastFocus } return m, nil } func (m *Model) handleUserViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.viewUser == nil || m.audioPlayer == nil { if msg.String() == "esc" || msg.String() == "q" { m.focus = FocusChannels m.showUserView = false } return m, nil } u := m.viewUser switch msg.String() { case "esc", "q": m.focus = FocusChannels m.showUserView = false case "1": // Initiate Poke: set target, clear input, and focus it m.pokeID = u.ID m.focus = FocusInput m.inputText = "" m.addLog("Write poke message for %s and press Enter...", u.Nickname) case "2": // Toggle mute for this user _, muted := m.audioPlayer.GetUserSettings(u.ID) m.audioPlayer.SetUserMuted(u.ID, !muted) m.addLog("Toggled mute for %s. Now muted: %v", u.Nickname, !muted) case "+", "=": // Increase volume currentVol, _ := m.audioPlayer.GetUserSettings(u.ID) newVol := currentVol + 0.1 if newVol > 2.0 { newVol = 2.0 } // Allow up to 200% boost m.audioPlayer.SetUserVolume(u.ID, newVol) case "-", "_": // Decrease volume currentVol, _ := m.audioPlayer.GetUserSettings(u.ID) newVol := currentVol - 0.1 if newVol < 0.0 { newVol = 0.0 } m.audioPlayer.SetUserVolume(u.ID, newVol) } return m, nil } func (m *Model) handleChannelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "up", "k": for m.selectedIdx > 0 { m.selectedIdx-- if !m.items[m.selectedIdx].IsSpacer() { break } // If we hit the top and it's a spacer, we might need to go down to find the first valid one if m.selectedIdx == 0 && m.items[m.selectedIdx].IsSpacer() { // Search forward for the first valid one for i := 0; i < len(m.items); i++ { if !m.items[i].IsSpacer() { m.selectedIdx = i break } } break } } case "down", "j": for m.selectedIdx < len(m.items)-1 { m.selectedIdx++ if !m.items[m.selectedIdx].IsSpacer() { break } } case "enter": // Join selected channel OR open user view if m.selectedIdx < len(m.items) && m.client != nil { item := m.items[m.selectedIdx] if item.IsSpacer() { return m, nil // Do nothing for spacers } if !item.IsUser { // Channel ch := item.Channel 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) } } else { // User m.viewUser = item.User m.showUserView = true m.focus = FocusUserView } } } // Bound check if m.selectedIdx >= len(m.items) && len(m.items) > 0 { m.selectedIdx = len(m.items) - 1 } 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 { if m.pokeID != 0 { err := m.client.Poke(m.pokeID, m.inputText) if err != nil { m.addLog("Error poking client %d: %v", m.pokeID, err) } else { m.addLog("Poke sent!") } m.pokeID = 0 m.focus = FocusUserView } else { m.client.SendChannelMessage(m.inputText) } m.inputText = "" } case "esc": if m.pokeID != 0 { m.pokeID = 0 m.focus = FocusUserView } else { 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.showPokePopup { boxStyle := lipgloss.NewStyle(). Border(lipgloss.DoubleBorder()). BorderForeground(lipgloss.Color("196")). // Red for Poke Padding(1, 2). Width(50). Align(lipgloss.Center) titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("226")).MarginBottom(1) senderStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Bold(true) msgStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Italic(true) helpStyle := lipgloss.NewStyle().Faint(true).MarginTop(2) content := lipgloss.JoinVertical(lipgloss.Center, titleStyle.Render("YOU WERE POKED!"), "", fmt.Sprintf("From: %s", senderStyle.Render(m.pokePopupSender)), "", msgStyle.Render(fmt.Sprintf("%q", m.pokePopupMessage)), "", helpStyle.Render("(Press Esc/Enter to close)"), ) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, boxStyle.Render(content)) } if m.focus == FocusAbout { return m.renderAboutView() } 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 // Calculate explicit widths to fit exactly (Width is content width) // Box Width = Content + 2 (Border) + 2 (Padding) = Content + 4 // We want LeftBox + RightBox = m.width leftBoxWidth := m.width / 4 rightBoxWidth := m.width - leftBoxWidth channelPanelStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("63")). Padding(0, 1). Width(leftBoxWidth - 4). Height(panelHeight) chatPanelStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("63")). Padding(0, 1). Width(rightBoxWidth). Height(panelHeight) inputStyle := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("63")). Padding(0, 1). Width(m.width - 2) // 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, Log, or User View) var rightPanel string if m.showUserView && m.viewUser != nil { // User View userViewContent := m.renderUserView() userViewStyle := chatPanelStyle.Copy().BorderForeground(lipgloss.Color("208")) // Orange focus rightPanel = userViewStyle.Render(userViewContent) } else 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 := (rightBoxWidth - 4) - 2 // Content width - margin 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 prompt := "> " if m.pokeID != 0 { prompt = "[Poke Message] > " } inputContent := prompt + 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("↑↓ nav │ Ent join │ Tab switch │ %s │ V PTT │ G VAD │ ^↕↔ thresh │ 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) // Custom Mic Bar with VAD Threshold micBarWidth := 8 var micBar string if m.vadEnabled { // Calculate threshold position threshPos := m.vadThreshold * micBarWidth / 100 if threshPos >= micBarWidth { threshPos = micBarWidth - 1 } // Calculate filled position based on current level filled := m.micLevel * micBarWidth / 100 // Build bar var sb strings.Builder for i := 0; i < micBarWidth; i++ { char := "░" if i < filled { char = "█" } // Overlay threshold marker if i == threshPos { if i < filled { // Threshold is met char = "▓" } else { // Threshold not met char = "│" } } sb.WriteString(char) } micBar = sb.String() } else { micBar = audio.LevelToBar(m.micLevel, micBarWidth) } pttStyle := lipgloss.NewStyle() pttIcon := "MIC" if m.isPTT { pttIcon = " ON" // Manual ON pttStyle = pttStyle.Foreground(lipgloss.Color("196")).Bold(true) // Red } else if m.vadEnabled { pttIcon = "VAD" // Check if actively transmitting (using logic with decay) isTransmitting := false if !m.isMuted { if m.micLevel > m.vadThreshold { isTransmitting = true } else if !m.vadLastTriggered.IsZero() && time.Since(m.vadLastTriggered) < 1*time.Second { isTransmitting = true } } if isTransmitting { // Transmitting via VAD: Red/Bold pttStyle = pttStyle.Foreground(lipgloss.Color("196")).Bold(true) } else { // Idle VAD: Gray/Faint pttStyle = pttStyle.Foreground(lipgloss.Color("240")).Faint(true) } } else { // Standard Mic (PTT mode but off) pttStyle = pttStyle.Foreground(lipgloss.Color("255")) // White } // Apply status bar background color to prevent cutting pttStyle = pttStyle.Background(lipgloss.Color("57")) // Matches Top Bar Background // Style for the bar itself to maintain background continuity barStyle := lipgloss.NewStyle().Background(lipgloss.Color("57")).Foreground(lipgloss.Color("255")) micPart := fmt.Sprintf("%s%s%s", pttStyle.Render(pttIcon), barStyle.Render(":"), barStyle.Render(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) } // Regex for TeamSpeak spacers: [spacer0], [cspacer], [*spacer], etc. var spacerRegex = regexp.MustCompile(`^\[([*cZr]?spacer[\w\d]*)\](.*)`) func (m *Model) renderChannels() string { if len(m.items) == 0 { return "No channels..." } var lines []string lines = append(lines, lipgloss.NewStyle().Bold(true).Render("CHANNELS")) lines = append(lines, "") for i, item := range m.items { // If selected isSelected := (i == m.selectedIdx) if !item.IsUser { // CHANNEL ch := item.Channel indent := strings.Repeat(" ", ch.Depth) prefix := indent + " " if isSelected { prefix = indent + "► " } style := lipgloss.NewStyle() if isSelected { style = style.Bold(true).Foreground(lipgloss.Color("212")) } displayName := ch.Name // Spacer rendering logic matches := spacerRegex.FindStringSubmatch(ch.Name) if len(matches) > 0 { tag := matches[1] content := matches[2] width := (m.width / 4) - 6 if width < 0 { width = 0 } if strings.HasPrefix(tag, "*") { if len(content) > 0 { count := width / len(content) if count > 0 { displayName = strings.Repeat(content, count+1)[:width] } } else { displayName = strings.Repeat("-", width) } } else if strings.HasPrefix(tag, "c") { gap := (width - len(content)) / 2 if gap > 0 { displayName = strings.Repeat(" ", gap) + content } else { displayName = content } } else { displayName = content } } lines = append(lines, style.Render(prefix+displayName)) } else { // USER user := item.User ch := item.Channel // Parent indent := strings.Repeat(" ", ch.Depth) prefix := indent + " └─ " if isSelected { prefix = indent + " => " } userStyle := lipgloss.NewStyle().Faint(true) if user.Talking { userStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("46")) prefix = indent + " 🔊 " } else if user.IsMe { userStyle = userStyle.Foreground(lipgloss.Color("82")) } if isSelected { userStyle = userStyle.Bold(true).Foreground(lipgloss.Color("212")) // Pink selection } lines = append(lines, userStyle.Render(prefix+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...) } func (m *Model) renderUserView() string { if m.viewUser == nil { return "No user selected" } u := m.viewUser // Get audio settings vol := float32(1.0) muted := false if m.audioPlayer != nil { vol, muted = m.audioPlayer.GetUserSettings(u.ID) } titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("208")).Underline(true) labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) valStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) muteStr := "No" if muted { muteStr = "YES" } // Info section info := []string{ titleStyle.Render(fmt.Sprintf("User: %s", u.Nickname)), "", fmt.Sprintf("%s %s", labelStyle.Render("ID:"), valStyle.Render(fmt.Sprintf("%d", u.ID))), fmt.Sprintf("%s %v", labelStyle.Render("Talking:"), valStyle.Render(fmt.Sprintf("%v", m.talkingClients[u.ID]))), fmt.Sprintf("%s %v", labelStyle.Render("Muted (Server):"), valStyle.Render(fmt.Sprintf("%v", u.Muted))), "", "--- Audio Settings ---", fmt.Sprintf("%s %d%%", labelStyle.Render("Volume:"), int(vol*100)), fmt.Sprintf("%s %s", labelStyle.Render("Local Mute:"), muteStr), "", "--- Menu ---", "1. Poke", "2. Toggle Local Mute", "+/-: Adjust Volume", "", "(Press ESC to close)", } return lipgloss.JoinVertical(lipgloss.Left, info...) } func (m *Model) renderAboutView() string { titleStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("205")). MarginBottom(1). Align(lipgloss.Center) boxStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("62")). Padding(2). Width(60). Align(lipgloss.Center) mainContent := lipgloss.JoinVertical(lipgloss.Center, titleStyle.Render("TS3 TUI CLIENT"), lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Render("Una terminal potente para tus comunidades."), "", lipgloss.NewStyle().Bold(true).Render("Realizado por JosLeDeta"), lipgloss.NewStyle().Italic(true).Faint(true).Render("Hecho en Antigravity"), "", lipgloss.NewStyle().Bold(true).Render("Con la ayuda de:"), lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Render("- Gemini 3 Pro"), lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Render("- Claude Opus 4.5"), "", "", lipgloss.NewStyle().Faint(true).Render("(Presiona ESC o F1 para volver)"), ) // Center everything on screen return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, boxStyle.Render(mainContent)) }