feat(tui): show voice activity with green color and speaker icon when users talk
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user