From c55ace0c00281697ab2ba935c3543569b2a5a044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es=20Ojados?= Date: Fri, 16 Jan 2026 19:50:44 +0100 Subject: [PATCH] feat(tui): show voice activity with green color and speaker icon when users talk --- cmd/tui/main.go | 3 ++ cmd/tui/model.go | 57 +++++++++++++++++++++++++++---- pkg/ts3client/client.go | 74 +++++++++++++++++++++++++++++++++++++---- pkg/ts3client/events.go | 9 ++++- 4 files changed, 129 insertions(+), 14 deletions(-) diff --git a/cmd/tui/main.go b/cmd/tui/main.go index cbf3290..07d08d5 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -48,6 +48,9 @@ func main() { // Create Bubble Tea program p := tea.NewProgram(m, tea.WithAltScreen()) + // Pass program reference to model for event handlers + m.SetProgram(p) + // Set up log capture r, w, _ := os.Pipe() if debugFile != nil { diff --git a/cmd/tui/model.go b/cmd/tui/model.go index 3dd7135..5c908d8 100644 --- a/cmd/tui/model.go +++ b/cmd/tui/model.go @@ -71,6 +71,12 @@ type Model struct { // Error handling lastError string + + // Voice activity + talkingClients map[uint16]bool // ClientID -> isTalking + + // Program reference for sending messages from event handlers + program *tea.Program } // addLog adds a message to the log panel @@ -86,15 +92,21 @@ func (m *Model) addLog(format string, args ...any) { // 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..."}, + serverAddr: serverAddr, + nickname: nickname, + focus: FocusChannels, + channels: []ChannelNode{}, + chatMessages: []ChatMessage{}, + logMessages: []string{"Starting..."}, + talkingClients: make(map[uint16]bool), } } +// 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( @@ -151,6 +163,11 @@ type chatMsg struct { message string } +type talkingStatusMsg struct { + clientID uint16 + talking bool +} + type errorMsg struct { err string } @@ -172,6 +189,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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, + }) + }) + } + // Connect asynchronously m.client.ConnectAsync() @@ -229,6 +256,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 logMsg: // External log message m.addLog("%s", string(msg)) @@ -264,6 +300,7 @@ func (m *Model) updateChannelList(channels []*ts3client.Channel) { ID: cl.ID, Nickname: cl.Nickname, IsMe: cl.ID == m.selfID, + Talking: m.talkingClients[cl.ID], }) } } @@ -478,10 +515,16 @@ func (m *Model) renderChannels() string { for _, user := range ch.Users { userPrefix := " └─ " userStyle := lipgloss.NewStyle().Faint(true) - if user.IsMe { + + // 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)) } } diff --git a/pkg/ts3client/client.go b/pkg/ts3client/client.go index 9ac80c7..6c61e05 100644 --- a/pkg/ts3client/client.go +++ b/pkg/ts3client/client.go @@ -29,6 +29,10 @@ type Client struct { selfInfo *SelfInfo channelsMu sync.RWMutex clientsMu sync.RWMutex + + // Voice activity tracking + talkingClients map[uint16]time.Time // ClientID -> last voice packet time + talkingMu sync.RWMutex } // New creates a new TeamSpeak client @@ -51,11 +55,12 @@ func New(address string, config Config) *Client { } return &Client{ - address: address, - config: config, - handlers: make(map[EventType][]any), - channels: make(map[uint64]*Channel), - clients: make(map[uint16]*ClientInfo), + address: address, + config: config, + handlers: make(map[EventType][]any), + channels: make(map[uint64]*Channel), + clients: make(map[uint16]*ClientInfo), + talkingClients: make(map[uint16]time.Time), } } @@ -115,6 +120,10 @@ func (c *Client) emit(event EventType, data any) { if fn, ok := h.(func(*ErrorEvent)); ok { fn(data.(*ErrorEvent)) } + case EventTalkingStatus: + if fn, ok := h.(func(*TalkingStatusEvent)); ok { + fn(data.(*TalkingStatusEvent)) + } } } } @@ -127,6 +136,9 @@ func (c *Client) Connect() error { // Set event callback on internal client c.internal.SetEventHandler(c.handleInternalEvent) + // Start talking timeout checker goroutine + go c.talkingTimeoutChecker() + log.Printf("Connecting to %s as %s...", c.address, c.config.Nickname) err := c.internal.Connect(c.address) @@ -140,6 +152,40 @@ func (c *Client) Connect() error { return nil } +// talkingTimeoutChecker runs in background and emits TalkingStatus=false when clients stop talking +func (c *Client) talkingTimeoutChecker() { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + const talkingTimeout = 300 * time.Millisecond + + for range ticker.C { + if !c.connected { + continue + } + + now := time.Now() + var stoppedTalking []uint16 + + c.talkingMu.Lock() + for clientID, lastTime := range c.talkingClients { + if now.Sub(lastTime) > talkingTimeout { + stoppedTalking = append(stoppedTalking, clientID) + delete(c.talkingClients, clientID) + } + } + c.talkingMu.Unlock() + + // Emit events for clients who stopped talking + for _, clientID := range stoppedTalking { + c.emit(EventTalkingStatus, &TalkingStatusEvent{ + ClientID: clientID, + Talking: false, + }) + } + } +} + // ConnectAsync connects in the background and returns immediately func (c *Client) ConnectAsync() <-chan error { errChan := make(chan error, 1) @@ -321,8 +367,24 @@ func (c *Client) handleInternalEvent(eventType string, data map[string]any) { } case "audio": + senderID := getUint16(data, "senderID") + + // Track talking status + c.talkingMu.Lock() + _, wasTalking := c.talkingClients[senderID] + c.talkingClients[senderID] = time.Now() + c.talkingMu.Unlock() + + // Emit talking start event if this is a new speaker + if !wasTalking { + c.emit(EventTalkingStatus, &TalkingStatusEvent{ + ClientID: senderID, + Talking: true, + }) + } + c.emit(EventAudio, &AudioEvent{ - SenderID: getUint16(data, "senderID"), + SenderID: senderID, Codec: AudioCodec(getInt(data, "codec")), PCM: getPCM(data, "pcm"), Channels: getInt(data, "channels"), diff --git a/pkg/ts3client/events.go b/pkg/ts3client/events.go index 1d3eafa..9d61c91 100644 --- a/pkg/ts3client/events.go +++ b/pkg/ts3client/events.go @@ -20,7 +20,8 @@ const ( EventChannelList EventType = "channel_list" // Audio events - EventAudio EventType = "audio" + EventAudio EventType = "audio" + EventTalkingStatus EventType = "talking_status" // Error events EventError EventType = "error" @@ -112,3 +113,9 @@ type ErrorEvent struct { ID string Message string } + +// TalkingStatusEvent is emitted when a client starts or stops talking +type TalkingStatusEvent struct { + ClientID uint16 + Talking bool +}