feat: Implement TeamSpeak 3 client with connection management, event handling, and audio support.

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-16 22:11:58 +01:00
parent c55ace0c00
commit f83f525600
7 changed files with 877 additions and 27 deletions

View File

@@ -6,6 +6,7 @@ import (
"strings"
"time"
"go-ts/pkg/audio"
"go-ts/pkg/ts3client"
tea "github.com/charmbracelet/bubbletea"
@@ -75,6 +76,14 @@ type Model struct {
// 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
// Program reference for sending messages from event handlers
program *tea.Program
}
@@ -99,6 +108,7 @@ func NewModel(serverAddr, nickname string) *Model {
chatMessages: []ChatMessage{},
logMessages: []string{"Starting..."},
talkingClients: make(map[uint16]bool),
playbackVol: 80, // Default 80% volume
}
}
@@ -172,6 +182,8 @@ type errorMsg struct {
err string
}
type micLevelMsg int // Mic level 0-100
type tickMsg time.Time
// Update handles messages and user input
@@ -197,6 +209,43 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
talking: e.Talking,
})
})
// Handle incoming audio - play through speakers
m.client.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) {
if m.audioPlayer != nil {
m.audioPlayer.PlayPCM(e.PCM)
}
})
}
// 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
m.audioCapturer.SetCallback(func(samples []int16) {
if m.isPTT && m.client != nil && !m.isMuted {
m.client.SendAudio(samples)
}
// Update mic level for display
if m.program != nil {
m.program.Send(micLevelMsg(m.audioCapturer.GetLevel()))
}
})
m.addLog("Audio capturer initialized")
}
// Connect asynchronously
@@ -236,8 +285,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
// Continue ticking
return m, tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
// Update mic level when PTT is active (multiply for better visibility)
if m.isPTT && m.audioCapturer != nil {
level := m.audioCapturer.GetLevel() * 4 // Boost for visibility
if level > 100 {
level = 100
}
m.micLevel = level
} else {
m.micLevel = 0 // Reset when not transmitting
}
// Continue ticking (100ms for responsive mic level)
return m, tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return tickMsg(t)
})
@@ -265,6 +325,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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))
@@ -315,12 +380,19 @@ func (m *Model) updateChannelList(channels []*ts3client.Channel) {
}
func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Global keys
// Global keys (work regardless of focus)
switch msg.String() {
case "ctrl+c", "q":
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":
@@ -328,9 +400,55 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.focus = (m.focus + 1) % 3
return m, nil
case "m":
// Toggle mute (would need to implement)
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 "v", "V":
// Toggle voice (PTT) - V to start/stop transmitting
if m.focus != FocusInput {
m.isPTT = !m.isPTT
if m.isPTT {
// Start capturing when PTT enabled
if m.audioCapturer != nil {
m.audioCapturer.Start()
}
m.addLog("🎤 Transmitting...")
} else {
// Stop capturing when PTT disabled
if m.audioCapturer != nil {
m.audioCapturer.Stop()
}
m.addLog("🎤 Stopped transmitting")
}
return m, nil
}
}
// Focus-specific keys
@@ -419,26 +537,23 @@ func (m *Model) View() string {
}
// Styles
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Padding(0, 1).
Width(m.width)
// 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
channelPanelStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(0, 1).
Width(m.width/3 - 2).
Height(m.height - 6)
Height(panelHeight)
chatPanelStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(0, 1).
Width(m.width*2/3 - 2).
Height(m.height - 6)
Height(panelHeight)
inputStyle := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
@@ -446,12 +561,8 @@ func (m *Model) View() string {
Padding(0, 1).
Width(m.width - 4)
// Header
status := "Connecting..."
if m.connected {
status = fmt.Sprintf("Server: %s │ You: %s (ID: %d)", m.serverName, m.nickname, m.selfID)
}
header := headerStyle.Render(status)
// Header with status bar
header := m.renderStatusBar()
// Channel panel
channelContent := m.renderChannels()
@@ -476,7 +587,7 @@ func (m *Model) View() string {
input := inputStyle.Render(inputContent)
// Footer help
help := lipgloss.NewStyle().Faint(true).Render("↑↓ navigate │ Enter join │ Tab switch │ q quit")
help := lipgloss.NewStyle().Faint(true).Render("↑↓ navigate │ Enter join │ Tab switch │ V talk │ M mute │ +/- vol │ q quit")
// Combine panels
panels := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, chatPanel)
@@ -489,6 +600,82 @@ func (m *Model) View() string {
)
}
// 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)
micBar := audio.LevelToBar(m.micLevel, 6)
pttIcon := "MIC"
if m.isPTT {
pttIcon = "*TX*"
}
micPart := fmt.Sprintf("%s:%s", pttIcon, 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)
}
func (m *Model) renderChannels() string {
if len(m.channels) == 0 {
return "No channels..."