feat: Implement TeamSpeak 3 client with connection management, event handling, and audio support.
This commit is contained in:
227
cmd/tui/model.go
227
cmd/tui/model.go
@@ -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..."
|
||||
|
||||
Reference in New Issue
Block a user