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
|
// 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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user