feat(tui): show voice activity with green color and speaker icon when users talk

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-16 19:50:44 +01:00
parent 13f444193d
commit c55ace0c00
4 changed files with 129 additions and 14 deletions

View File

@@ -48,6 +48,9 @@ func main() {
// Create Bubble Tea program // Create Bubble Tea program
p := tea.NewProgram(m, tea.WithAltScreen()) p := tea.NewProgram(m, tea.WithAltScreen())
// Pass program reference to model for event handlers
m.SetProgram(p)
// Set up log capture // Set up log capture
r, w, _ := os.Pipe() r, w, _ := os.Pipe()
if debugFile != nil { if debugFile != nil {

View File

@@ -71,6 +71,12 @@ type Model struct {
// Error handling // Error handling
lastError string 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 // addLog adds a message to the log panel
@@ -92,9 +98,15 @@ func NewModel(serverAddr, nickname string) *Model {
channels: []ChannelNode{}, channels: []ChannelNode{},
chatMessages: []ChatMessage{}, chatMessages: []ChatMessage{},
logMessages: []string{"Starting..."}, 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 // Init is called when the program starts
func (m *Model) Init() tea.Cmd { func (m *Model) Init() tea.Cmd {
return tea.Batch( return tea.Batch(
@@ -151,6 +163,11 @@ type chatMsg struct {
message string message string
} }
type talkingStatusMsg struct {
clientID uint16
talking bool
}
type errorMsg struct { type errorMsg struct {
err string err string
} }
@@ -172,6 +189,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.client = msg.client m.client = msg.client
m.connecting = true 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 // Connect asynchronously
m.client.ConnectAsync() m.client.ConnectAsync()
@@ -229,6 +256,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.lastError = msg.err m.lastError = msg.err
return m, nil 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: case logMsg:
// External log message // External log message
m.addLog("%s", string(msg)) m.addLog("%s", string(msg))
@@ -264,6 +300,7 @@ func (m *Model) updateChannelList(channels []*ts3client.Channel) {
ID: cl.ID, ID: cl.ID,
Nickname: cl.Nickname, Nickname: cl.Nickname,
IsMe: cl.ID == m.selfID, IsMe: cl.ID == m.selfID,
Talking: m.talkingClients[cl.ID],
}) })
} }
} }
@@ -478,10 +515,16 @@ func (m *Model) renderChannels() string {
for _, user := range ch.Users { for _, user := range ch.Users {
userPrefix := " └─ " userPrefix := " └─ "
userStyle := lipgloss.NewStyle().Faint(true) 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")) userStyle = userStyle.Foreground(lipgloss.Color("82"))
userPrefix = " └─ ► " userPrefix = " └─ ► "
} }
lines = append(lines, userStyle.Render(userPrefix+user.Nickname)) lines = append(lines, userStyle.Render(userPrefix+user.Nickname))
} }
} }

View File

@@ -29,6 +29,10 @@ type Client struct {
selfInfo *SelfInfo selfInfo *SelfInfo
channelsMu sync.RWMutex channelsMu sync.RWMutex
clientsMu 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 // New creates a new TeamSpeak client
@@ -56,6 +60,7 @@ func New(address string, config Config) *Client {
handlers: make(map[EventType][]any), handlers: make(map[EventType][]any),
channels: make(map[uint64]*Channel), channels: make(map[uint64]*Channel),
clients: make(map[uint16]*ClientInfo), 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 { if fn, ok := h.(func(*ErrorEvent)); ok {
fn(data.(*ErrorEvent)) 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 // Set event callback on internal client
c.internal.SetEventHandler(c.handleInternalEvent) 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) log.Printf("Connecting to %s as %s...", c.address, c.config.Nickname)
err := c.internal.Connect(c.address) err := c.internal.Connect(c.address)
@@ -140,6 +152,40 @@ func (c *Client) Connect() error {
return nil 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 // ConnectAsync connects in the background and returns immediately
func (c *Client) ConnectAsync() <-chan error { func (c *Client) ConnectAsync() <-chan error {
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -321,8 +367,24 @@ func (c *Client) handleInternalEvent(eventType string, data map[string]any) {
} }
case "audio": 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{ c.emit(EventAudio, &AudioEvent{
SenderID: getUint16(data, "senderID"), SenderID: senderID,
Codec: AudioCodec(getInt(data, "codec")), Codec: AudioCodec(getInt(data, "codec")),
PCM: getPCM(data, "pcm"), PCM: getPCM(data, "pcm"),
Channels: getInt(data, "channels"), Channels: getInt(data, "channels"),

View File

@@ -21,6 +21,7 @@ const (
// Audio events // Audio events
EventAudio EventType = "audio" EventAudio EventType = "audio"
EventTalkingStatus EventType = "talking_status"
// Error events // Error events
EventError EventType = "error" EventError EventType = "error"
@@ -112,3 +113,9 @@ type ErrorEvent struct {
ID string ID string
Message string Message string
} }
// TalkingStatusEvent is emitted when a client starts or stops talking
type TalkingStatusEvent struct {
ClientID uint16
Talking bool
}