2026-01-16 14:41:26 +01:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
2026-01-16 23:54:36 +01:00
|
|
|
"regexp"
|
2026-01-16 14:41:26 +01:00
|
|
|
"sort"
|
2026-01-16 16:02:17 +01:00
|
|
|
"strings"
|
2026-01-16 14:41:26 +01:00
|
|
|
"time"
|
|
|
|
|
|
2026-01-16 22:11:58 +01:00
|
|
|
"go-ts/pkg/audio"
|
2026-01-16 14:41:26 +01:00
|
|
|
"go-ts/pkg/ts3client"
|
|
|
|
|
|
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Focus indicates which panel has focus
|
|
|
|
|
type Focus int
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
FocusChannels Focus = iota
|
|
|
|
|
FocusChat
|
|
|
|
|
FocusInput
|
2026-01-17 00:19:49 +01:00
|
|
|
FocusUserView
|
2026-01-17 02:01:49 +01:00
|
|
|
FocusAbout
|
2026-01-16 14:41:26 +01:00
|
|
|
)
|
|
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
// ListItem represents an item in the navigation tree (Channel or User)
|
|
|
|
|
type ListItem struct {
|
|
|
|
|
IsUser bool
|
|
|
|
|
Channel *ChannelNode
|
|
|
|
|
User *UserNode
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 00:58:55 +01:00
|
|
|
func (l ListItem) IsSpacer() bool {
|
|
|
|
|
if l.IsUser || l.Channel == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
// TeamSpeak spacers usually look like [spacer0], [*spacer1], etc.
|
|
|
|
|
return strings.Contains(strings.ToLower(l.Channel.Name), "[spacer")
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
// ChatMessage represents a message in the chat
|
|
|
|
|
type ChatMessage struct {
|
|
|
|
|
Time time.Time
|
|
|
|
|
Sender string
|
|
|
|
|
Content string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ChannelNode represents a channel in the tree
|
|
|
|
|
type ChannelNode struct {
|
|
|
|
|
ID uint64
|
|
|
|
|
Name string
|
|
|
|
|
Users []UserNode
|
|
|
|
|
Expanded bool
|
|
|
|
|
Selected bool
|
2026-01-16 23:54:36 +01:00
|
|
|
Depth int
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UserNode represents a user in a channel
|
|
|
|
|
type UserNode struct {
|
|
|
|
|
ID uint16
|
|
|
|
|
Nickname string
|
|
|
|
|
Talking bool
|
|
|
|
|
Muted bool
|
|
|
|
|
IsMe bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Model is the Bubble Tea model for our TUI
|
|
|
|
|
type Model struct {
|
|
|
|
|
// Connection
|
|
|
|
|
serverAddr string
|
|
|
|
|
nickname string
|
|
|
|
|
client *ts3client.Client
|
|
|
|
|
connected bool
|
|
|
|
|
connecting bool
|
|
|
|
|
serverName string
|
|
|
|
|
selfID uint16
|
|
|
|
|
ping int
|
|
|
|
|
|
|
|
|
|
// UI State
|
|
|
|
|
focus Focus
|
2026-01-17 02:01:49 +01:00
|
|
|
lastFocus Focus // To return from About view
|
2026-01-16 14:41:26 +01:00
|
|
|
width, height int
|
|
|
|
|
channels []ChannelNode
|
2026-01-17 00:19:49 +01:00
|
|
|
items []ListItem // Flattened list for navigation
|
2026-01-16 14:41:26 +01:00
|
|
|
selectedIdx int
|
|
|
|
|
chatMessages []ChatMessage
|
|
|
|
|
logMessages []string // Debug logs shown in chat panel
|
|
|
|
|
inputText string
|
|
|
|
|
inputActive bool
|
|
|
|
|
|
|
|
|
|
// Error handling
|
|
|
|
|
lastError string
|
2026-01-16 19:50:44 +01:00
|
|
|
|
|
|
|
|
// Voice activity
|
|
|
|
|
talkingClients map[uint16]bool // ClientID -> isTalking
|
|
|
|
|
|
2026-01-16 22:11:58 +01:00
|
|
|
// Audio state
|
2026-01-17 17:02:18 +01:00
|
|
|
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 (Manual TX)
|
|
|
|
|
vadEnabled bool // Voice Activation Detection active
|
|
|
|
|
vadThreshold int // 0-100 threshold for VAD
|
|
|
|
|
vadLastTriggered time.Time // Last time VAD threshold was exceeded
|
2026-01-16 22:11:58 +01:00
|
|
|
|
2026-01-17 15:57:34 +01:00
|
|
|
// Popup State
|
|
|
|
|
showPokePopup bool
|
|
|
|
|
pokePopupSender string
|
|
|
|
|
pokePopupMessage string
|
|
|
|
|
|
2026-01-16 19:50:44 +01:00
|
|
|
// Program reference for sending messages from event handlers
|
|
|
|
|
program *tea.Program
|
2026-01-16 22:33:35 +01:00
|
|
|
showLog bool
|
2026-01-17 00:19:49 +01:00
|
|
|
|
|
|
|
|
// User View
|
|
|
|
|
showUserView bool
|
|
|
|
|
viewUser *UserNode
|
2026-01-17 00:25:42 +01:00
|
|
|
pokeID uint16 // Target ID for pending poke
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// addLog adds a message to the log panel
|
|
|
|
|
func (m *Model) addLog(format string, args ...any) {
|
|
|
|
|
msg := fmt.Sprintf(format, args...)
|
|
|
|
|
m.logMessages = append(m.logMessages, msg)
|
|
|
|
|
// Keep last 50 messages
|
|
|
|
|
if len(m.logMessages) > 50 {
|
|
|
|
|
m.logMessages = m.logMessages[1:]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewModel creates a new TUI model
|
|
|
|
|
func NewModel(serverAddr, nickname string) *Model {
|
|
|
|
|
return &Model{
|
2026-01-16 19:50:44 +01:00
|
|
|
serverAddr: serverAddr,
|
|
|
|
|
nickname: nickname,
|
|
|
|
|
focus: FocusChannels,
|
|
|
|
|
channels: []ChannelNode{},
|
|
|
|
|
chatMessages: []ChatMessage{},
|
|
|
|
|
logMessages: []string{"Starting..."},
|
|
|
|
|
talkingClients: make(map[uint16]bool),
|
2026-01-16 22:11:58 +01:00
|
|
|
playbackVol: 80, // Default 80% volume
|
2026-01-17 17:02:18 +01:00
|
|
|
vadEnabled: true,
|
|
|
|
|
vadThreshold: 50,
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 19:50:44 +01:00
|
|
|
// SetProgram sets the tea.Program reference for sending messages from event handlers
|
|
|
|
|
func (m *Model) SetProgram(p *tea.Program) {
|
|
|
|
|
m.program = p
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
// Init is called when the program starts
|
|
|
|
|
func (m *Model) Init() tea.Cmd {
|
|
|
|
|
return tea.Batch(
|
|
|
|
|
m.connectToServer(),
|
|
|
|
|
tea.SetWindowTitle("TeamSpeak TUI"),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TS3Event wraps events from the TS3 client
|
|
|
|
|
type TS3Event struct {
|
|
|
|
|
Type string
|
|
|
|
|
Data map[string]any
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// connectToServer initiates connection to TeamSpeak
|
|
|
|
|
func (m *Model) connectToServer() tea.Cmd {
|
|
|
|
|
return func() tea.Msg {
|
|
|
|
|
m.connecting = true
|
|
|
|
|
|
|
|
|
|
client := ts3client.New(m.serverAddr, ts3client.Config{
|
|
|
|
|
Nickname: m.nickname,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return ts3ClientMsg{client: client}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ts3ClientMsg struct {
|
|
|
|
|
client *ts3client.Client
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type connectedMsg struct {
|
|
|
|
|
clientID uint16
|
|
|
|
|
serverName string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type channelListMsg struct {
|
|
|
|
|
channels []*ts3client.Channel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type clientEnterMsg struct {
|
|
|
|
|
clientID uint16
|
|
|
|
|
nickname string
|
|
|
|
|
channelID uint64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type clientLeftMsg struct {
|
|
|
|
|
clientID uint16
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 00:53:50 +01:00
|
|
|
type clientMovedMsg struct {
|
|
|
|
|
clientID uint16
|
|
|
|
|
channelID uint64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type pokeMsg struct {
|
|
|
|
|
senderName string
|
|
|
|
|
message string
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
type chatMsg struct {
|
|
|
|
|
senderID uint16
|
|
|
|
|
senderName string
|
|
|
|
|
message string
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 19:50:44 +01:00
|
|
|
type talkingStatusMsg struct {
|
|
|
|
|
clientID uint16
|
|
|
|
|
talking bool
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
type errorMsg struct {
|
|
|
|
|
err string
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 22:11:58 +01:00
|
|
|
type micLevelMsg int // Mic level 0-100
|
|
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
type tickMsg time.Time
|
|
|
|
|
|
|
|
|
|
// Update handles messages and user input
|
|
|
|
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
|
|
switch msg := msg.(type) {
|
|
|
|
|
case tea.WindowSizeMsg:
|
|
|
|
|
m.width = msg.Width
|
|
|
|
|
m.height = msg.Height
|
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
|
|
case tea.KeyMsg:
|
|
|
|
|
return m.handleKeyPress(msg)
|
|
|
|
|
|
|
|
|
|
case ts3ClientMsg:
|
|
|
|
|
m.client = msg.client
|
|
|
|
|
m.connecting = true
|
|
|
|
|
|
2026-01-16 19:50:44 +01:00
|
|
|
// 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,
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-01-16 22:11:58 +01:00
|
|
|
|
2026-01-16 22:41:26 +01:00
|
|
|
m.client.On(ts3client.EventMessage, func(e *ts3client.MessageEvent) {
|
|
|
|
|
m.program.Send(chatMsg{
|
|
|
|
|
senderID: e.SenderID,
|
|
|
|
|
senderName: e.SenderName,
|
|
|
|
|
message: e.Message,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-16 22:11:58 +01:00
|
|
|
// Handle incoming audio - play through speakers
|
|
|
|
|
m.client.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) {
|
|
|
|
|
if m.audioPlayer != nil {
|
2026-01-16 22:33:35 +01:00
|
|
|
m.audioPlayer.PlayPCM(e.SenderID, e.PCM)
|
2026-01-16 22:11:58 +01:00
|
|
|
}
|
|
|
|
|
})
|
2026-01-17 00:53:50 +01:00
|
|
|
|
|
|
|
|
m.client.On(ts3client.EventClientMoved, func(e *ts3client.ClientMovedEvent) {
|
|
|
|
|
m.program.Send(clientMovedMsg{
|
|
|
|
|
clientID: e.ClientID,
|
|
|
|
|
channelID: e.ChannelID,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
m.client.On(ts3client.EventPoke, func(e *ts3client.PokeEvent) {
|
|
|
|
|
m.program.Send(pokeMsg{
|
|
|
|
|
senderName: e.SenderName,
|
|
|
|
|
message: e.Message,
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-01-16 22:11:58 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-01-17 17:02:18 +01:00
|
|
|
// Set callback to send audio to server when PTT is active
|
2026-01-16 22:11:58 +01:00
|
|
|
m.audioCapturer.SetCallback(func(samples []int16) {
|
2026-01-17 17:02:18 +01:00
|
|
|
// Calculate level of this frame for VAD decision
|
|
|
|
|
// Note: GetLevel() is smoothed, we might want instant frame level for VAD trigger?
|
|
|
|
|
// But pkg/audio/level.go is efficient. Let's re-calculate for precision.
|
|
|
|
|
level := audio.CalculateRMSLevel(samples)
|
|
|
|
|
|
|
|
|
|
// Determine if we should transmit
|
|
|
|
|
shouldTransmit := false
|
|
|
|
|
|
|
|
|
|
// Manual PTT (Locked on with 'v')
|
|
|
|
|
if m.isPTT {
|
|
|
|
|
shouldTransmit = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// VAD Logic
|
|
|
|
|
if m.vadEnabled && !m.isMuted {
|
|
|
|
|
if level > m.vadThreshold {
|
|
|
|
|
shouldTransmit = true
|
|
|
|
|
m.vadLastTriggered = time.Now()
|
|
|
|
|
} else if !m.vadLastTriggered.IsZero() && time.Since(m.vadLastTriggered) < 1*time.Second {
|
|
|
|
|
// Hold VAD open for 1 second (decay)
|
|
|
|
|
shouldTransmit = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Allow transmission if forced or VAD triggered
|
|
|
|
|
if shouldTransmit && m.client != nil && !m.isMuted {
|
2026-01-16 22:11:58 +01:00
|
|
|
m.client.SendAudio(samples)
|
|
|
|
|
}
|
2026-01-17 17:02:18 +01:00
|
|
|
|
|
|
|
|
// Update mic level for display (use the calculated level)
|
2026-01-16 22:11:58 +01:00
|
|
|
if m.program != nil {
|
2026-01-17 17:06:46 +01:00
|
|
|
// Use goroutine to prevent blocking the capture loop if the UI is busy (e.g. shutting down)
|
|
|
|
|
go m.program.Send(micLevelMsg(level))
|
2026-01-16 22:11:58 +01:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
m.addLog("Audio capturer initialized")
|
2026-01-17 17:02:18 +01:00
|
|
|
|
|
|
|
|
// Start capture immediately if VAD is enabled or PTT is active
|
|
|
|
|
if m.vadEnabled || m.isPTT {
|
|
|
|
|
if err := m.audioCapturer.Start(); err != nil {
|
|
|
|
|
m.addLog("Error starting audio capture: %v", err)
|
|
|
|
|
} else {
|
|
|
|
|
m.addLog("Audio capture started (VAD/PTT active)")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-16 19:50:44 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
// Connect asynchronously
|
|
|
|
|
m.client.ConnectAsync()
|
|
|
|
|
|
|
|
|
|
// Set up a ticker to poll for state changes
|
|
|
|
|
return m, tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
|
|
|
|
|
return tickMsg(t)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
case tickMsg:
|
|
|
|
|
// Poll client state
|
|
|
|
|
if m.client != nil {
|
|
|
|
|
// Check if connected
|
|
|
|
|
if !m.connected {
|
|
|
|
|
info := m.client.GetSelfInfo()
|
|
|
|
|
serverInfo := m.client.GetServerInfo()
|
|
|
|
|
m.addLog("Tick: selfInfo=%v, serverInfo=%v", info != nil, serverInfo != nil)
|
|
|
|
|
if info != nil && serverInfo != nil {
|
|
|
|
|
m.connected = true
|
|
|
|
|
m.selfID = info.ClientID
|
|
|
|
|
m.serverName = serverInfo.Name
|
|
|
|
|
m.addLog("Connected! ClientID=%d, Server=%s", m.selfID, m.serverName)
|
|
|
|
|
|
|
|
|
|
// Get channels
|
|
|
|
|
channels := m.client.GetChannels()
|
|
|
|
|
m.addLog("Got %d channels", len(channels))
|
|
|
|
|
m.updateChannelList(channels)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Update channel list periodically
|
|
|
|
|
channels := m.client.GetChannels()
|
|
|
|
|
if len(channels) != len(m.channels) {
|
|
|
|
|
m.addLog("Channel update: got %d channels (had %d)", len(channels), len(m.channels))
|
|
|
|
|
}
|
|
|
|
|
m.updateChannelList(channels)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 17:02:18 +01:00
|
|
|
// Legacy mic level handling removed to support VAD event-driven updates
|
2026-01-16 22:11:58 +01:00
|
|
|
|
|
|
|
|
// Continue ticking (100ms for responsive mic level)
|
|
|
|
|
return m, tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
|
2026-01-16 14:41:26 +01:00
|
|
|
return tickMsg(t)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
case connectedMsg:
|
|
|
|
|
m.connected = true
|
|
|
|
|
m.connecting = false
|
|
|
|
|
m.selfID = msg.clientID
|
|
|
|
|
m.serverName = msg.serverName
|
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
|
|
case channelListMsg:
|
|
|
|
|
m.updateChannelList(msg.channels)
|
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
|
|
case errorMsg:
|
|
|
|
|
m.lastError = msg.err
|
|
|
|
|
return m, nil
|
2026-01-16 16:02:17 +01:00
|
|
|
|
2026-01-16 19:50:44 +01:00
|
|
|
case talkingStatusMsg:
|
|
|
|
|
// Update talking status for client
|
|
|
|
|
if msg.talking {
|
|
|
|
|
m.talkingClients[msg.clientID] = true
|
|
|
|
|
} else {
|
|
|
|
|
delete(m.talkingClients, msg.clientID)
|
|
|
|
|
}
|
|
|
|
|
return m, nil
|
|
|
|
|
|
2026-01-17 00:53:50 +01:00
|
|
|
case clientMovedMsg:
|
|
|
|
|
if msg.clientID == m.selfID {
|
|
|
|
|
chName := "Unknown"
|
|
|
|
|
if ch := m.client.GetChannel(msg.channelID); ch != nil {
|
|
|
|
|
chName = ch.Name
|
|
|
|
|
}
|
|
|
|
|
m.chatMessages = append(m.chatMessages, ChatMessage{
|
|
|
|
|
Time: time.Now(),
|
|
|
|
|
Sender: "SYSTEM",
|
|
|
|
|
Content: fmt.Sprintf("You moved to channel: %s", chName),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
|
|
case pokeMsg:
|
2026-01-17 15:57:34 +01:00
|
|
|
// Append to chat as well
|
2026-01-17 00:53:50 +01:00
|
|
|
m.chatMessages = append(m.chatMessages, ChatMessage{
|
|
|
|
|
Time: time.Now(),
|
|
|
|
|
Sender: "POKE",
|
|
|
|
|
Content: fmt.Sprintf("[%s]: %s", msg.senderName, msg.message),
|
|
|
|
|
})
|
|
|
|
|
m.addLog("Received poke from %s: %s", msg.senderName, msg.message)
|
2026-01-17 15:57:34 +01:00
|
|
|
|
|
|
|
|
// Trigger Popup
|
|
|
|
|
m.showPokePopup = true
|
|
|
|
|
m.pokePopupSender = msg.senderName
|
|
|
|
|
m.pokePopupMessage = msg.message
|
|
|
|
|
|
2026-01-17 00:53:50 +01:00
|
|
|
return m, nil
|
|
|
|
|
|
2026-01-16 22:41:26 +01:00
|
|
|
case chatMsg:
|
|
|
|
|
m.chatMessages = append(m.chatMessages, ChatMessage{
|
|
|
|
|
Time: time.Now(),
|
|
|
|
|
Sender: msg.senderName,
|
|
|
|
|
Content: msg.message,
|
|
|
|
|
})
|
|
|
|
|
// Keep last 100 messages
|
|
|
|
|
if len(m.chatMessages) > 100 {
|
|
|
|
|
m.chatMessages = m.chatMessages[1:]
|
|
|
|
|
}
|
|
|
|
|
return m, nil
|
|
|
|
|
|
2026-01-16 22:11:58 +01:00
|
|
|
case micLevelMsg:
|
|
|
|
|
// Update microphone level for display
|
|
|
|
|
m.micLevel = int(msg)
|
|
|
|
|
return m, nil
|
|
|
|
|
|
2026-01-16 16:02:17 +01:00
|
|
|
case logMsg:
|
|
|
|
|
// External log message
|
|
|
|
|
m.addLog("%s", string(msg))
|
|
|
|
|
return m, nil
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 16:02:17 +01:00
|
|
|
type logMsg string
|
|
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
func (m *Model) updateChannelList(channels []*ts3client.Channel) {
|
2026-01-16 23:54:36 +01:00
|
|
|
// Build adjacency map: ParentID -> PreviousID(Order) -> Channel
|
|
|
|
|
levelMap := make(map[uint64]map[uint64]*ts3client.Channel)
|
|
|
|
|
for _, ch := range channels {
|
|
|
|
|
if levelMap[ch.ParentID] == nil {
|
|
|
|
|
levelMap[ch.ParentID] = make(map[uint64]*ts3client.Channel)
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
2026-01-16 23:54:36 +01:00
|
|
|
levelMap[ch.ParentID][ch.Order] = ch
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
|
2026-01-16 23:54:36 +01:00
|
|
|
var sortedNodes []ChannelNode
|
|
|
|
|
|
|
|
|
|
// Recursive function to flatten the tree in order
|
|
|
|
|
var visit func(parentID uint64, depth int)
|
|
|
|
|
visit = func(parentID uint64, depth int) {
|
|
|
|
|
prevID := uint64(0)
|
|
|
|
|
for {
|
|
|
|
|
ch, ok := levelMap[parentID][prevID]
|
|
|
|
|
if !ok {
|
|
|
|
|
break // End of list for this parent
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create node
|
|
|
|
|
node := ChannelNode{
|
|
|
|
|
ID: ch.ID,
|
|
|
|
|
Name: ch.Name,
|
|
|
|
|
Users: []UserNode{},
|
|
|
|
|
Expanded: true,
|
|
|
|
|
Depth: depth,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get users in this channel
|
|
|
|
|
for _, cl := range m.client.GetClients() {
|
|
|
|
|
if cl.ChannelID == ch.ID {
|
|
|
|
|
node.Users = append(node.Users, UserNode{
|
|
|
|
|
ID: cl.ID,
|
|
|
|
|
Nickname: cl.Nickname,
|
|
|
|
|
IsMe: cl.ID == m.selfID,
|
|
|
|
|
Talking: m.talkingClients[cl.ID],
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sort users by ID for stable ordering
|
|
|
|
|
sort.Slice(node.Users, func(i, j int) bool {
|
|
|
|
|
return node.Users[i].ID < node.Users[j].ID
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
sortedNodes = append(sortedNodes, node)
|
|
|
|
|
|
|
|
|
|
// Recursively visit children
|
|
|
|
|
visit(ch.ID, depth+1)
|
2026-01-16 16:02:17 +01:00
|
|
|
|
2026-01-16 23:54:36 +01:00
|
|
|
// Move to next sibling
|
|
|
|
|
prevID = ch.ID
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
2026-01-16 23:54:36 +01:00
|
|
|
|
|
|
|
|
// Start from root (ParentID = 0)
|
|
|
|
|
visit(0, 0)
|
|
|
|
|
|
|
|
|
|
// In case there are orphaned channels (shouldn't happen on standard server), standard logic ignores them.
|
|
|
|
|
// We'll stick to valid tree.
|
|
|
|
|
|
|
|
|
|
m.channels = sortedNodes
|
2026-01-17 00:19:49 +01:00
|
|
|
|
|
|
|
|
// Build flattened list for navigation
|
|
|
|
|
m.items = make([]ListItem, 0)
|
|
|
|
|
for i := range m.channels {
|
|
|
|
|
ch := &m.channels[i]
|
|
|
|
|
m.items = append(m.items, ListItem{IsUser: false, Channel: ch})
|
|
|
|
|
|
|
|
|
|
if ch.Expanded {
|
|
|
|
|
for j := range ch.Users {
|
|
|
|
|
u := &ch.Users[j]
|
|
|
|
|
m.items = append(m.items, ListItem{IsUser: true, User: u, Channel: ch})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-17 00:58:55 +01:00
|
|
|
|
|
|
|
|
// Ensure selectedIdx is valid (not on a spacer)
|
|
|
|
|
if len(m.items) > 0 {
|
|
|
|
|
if m.selectedIdx >= len(m.items) {
|
|
|
|
|
m.selectedIdx = len(m.items) - 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If current is a spacer, find next valid one
|
|
|
|
|
if m.items[m.selectedIdx].IsSpacer() {
|
|
|
|
|
found := false
|
|
|
|
|
// Try going down
|
|
|
|
|
for i := m.selectedIdx; i < len(m.items); i++ {
|
|
|
|
|
if !m.items[i].IsSpacer() {
|
|
|
|
|
m.selectedIdx = i
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// If not found, try going up
|
|
|
|
|
if !found {
|
|
|
|
|
for i := m.selectedIdx; i >= 0; i-- {
|
|
|
|
|
if !m.items[i].IsSpacer() {
|
|
|
|
|
m.selectedIdx = i
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
2026-01-17 15:57:34 +01:00
|
|
|
key := msg.String()
|
|
|
|
|
|
|
|
|
|
// Global Key Handling for Popup
|
|
|
|
|
if m.showPokePopup {
|
|
|
|
|
if key == "esc" || key == "enter" || key == "q" {
|
|
|
|
|
m.showPokePopup = false
|
|
|
|
|
}
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 22:52:10 +01:00
|
|
|
// 1. Absolute Globals (Always active)
|
2026-01-17 15:57:34 +01:00
|
|
|
switch key {
|
2026-01-16 22:52:10 +01:00
|
|
|
case "ctrl+c":
|
2026-01-16 14:41:26 +01:00
|
|
|
if m.client != nil {
|
|
|
|
|
m.client.Disconnect()
|
|
|
|
|
}
|
2026-01-16 22:11:58 +01:00
|
|
|
// Cleanup audio
|
|
|
|
|
if m.audioPlayer != nil {
|
|
|
|
|
m.audioPlayer.Close()
|
|
|
|
|
}
|
|
|
|
|
if m.audioCapturer != nil {
|
|
|
|
|
m.audioCapturer.Close()
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
return m, tea.Quit
|
|
|
|
|
|
|
|
|
|
case "tab":
|
|
|
|
|
// Cycle focus
|
2026-01-17 02:01:49 +01:00
|
|
|
m.focus = (m.focus + 1) % 4 // FocusChannels, FocusChat, FocusInput, FocusUserView
|
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
|
|
case "f1":
|
|
|
|
|
if m.focus == FocusAbout {
|
|
|
|
|
m.focus = m.lastFocus
|
|
|
|
|
} else {
|
|
|
|
|
m.lastFocus = m.focus
|
|
|
|
|
m.focus = FocusAbout
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
return m, nil
|
2026-01-16 22:52:10 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Input Focus Priority
|
|
|
|
|
// If typing, ignore all other shortcuts except the absolute globals above
|
|
|
|
|
if m.focus == FocusInput {
|
|
|
|
|
return m.handleInputKeys(msg)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 02:01:49 +01:00
|
|
|
// 3. Global Shortcuts (Only when NOT in Input or About)
|
|
|
|
|
if m.focus != FocusInput && m.focus != FocusUserView && m.focus != FocusAbout {
|
2026-01-17 00:19:49 +01:00
|
|
|
switch msg.String() {
|
|
|
|
|
case "q":
|
|
|
|
|
// Quit (same as ctrl+c)
|
|
|
|
|
if m.client != nil {
|
|
|
|
|
m.client.Disconnect()
|
|
|
|
|
}
|
|
|
|
|
if m.audioPlayer != nil {
|
|
|
|
|
m.audioPlayer.Close()
|
|
|
|
|
}
|
|
|
|
|
if m.audioCapturer != nil {
|
|
|
|
|
m.audioCapturer.Close()
|
|
|
|
|
}
|
|
|
|
|
return m, tea.Quit
|
2026-01-16 22:11:58 +01:00
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
case "m", "M":
|
|
|
|
|
// Toggle mute
|
|
|
|
|
m.isMuted = !m.isMuted
|
|
|
|
|
if m.audioPlayer != nil {
|
|
|
|
|
m.audioPlayer.SetMuted(m.isMuted)
|
|
|
|
|
}
|
|
|
|
|
return m, nil
|
2026-01-16 22:11:58 +01:00
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
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
|
2026-01-16 22:11:58 +01:00
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
case "-", "_":
|
|
|
|
|
// Decrease volume
|
|
|
|
|
m.playbackVol -= 10
|
|
|
|
|
if m.playbackVol < 0 {
|
|
|
|
|
m.playbackVol = 0
|
2026-01-16 22:11:58 +01:00
|
|
|
}
|
2026-01-17 00:19:49 +01:00
|
|
|
if m.audioPlayer != nil {
|
|
|
|
|
m.audioPlayer.SetVolume(float32(m.playbackVol) / 100.0)
|
2026-01-16 22:52:10 +01:00
|
|
|
}
|
2026-01-17 00:19:49 +01:00
|
|
|
return m, nil
|
|
|
|
|
|
2026-01-17 17:02:18 +01:00
|
|
|
case "ctrl+up", "ctrl+right":
|
|
|
|
|
// Increase VAD threshold
|
|
|
|
|
m.vadThreshold += 5
|
|
|
|
|
if m.vadThreshold > 100 {
|
|
|
|
|
m.vadThreshold = 100
|
|
|
|
|
}
|
|
|
|
|
m.addLog("VAD Threshold: %d", m.vadThreshold)
|
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
|
|
case "ctrl+down", "ctrl+left":
|
|
|
|
|
// Decrease VAD threshold
|
|
|
|
|
m.vadThreshold -= 5
|
|
|
|
|
if m.vadThreshold < 0 {
|
|
|
|
|
m.vadThreshold = 0
|
|
|
|
|
}
|
|
|
|
|
m.addLog("VAD Threshold: %d", m.vadThreshold)
|
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
|
|
case "g", "G":
|
|
|
|
|
// Toggle VAD (Gate)
|
|
|
|
|
m.vadEnabled = !m.vadEnabled
|
|
|
|
|
state := "OFF"
|
|
|
|
|
if m.vadEnabled {
|
|
|
|
|
state = "ON"
|
|
|
|
|
}
|
|
|
|
|
m.addLog("Voice Activation (Gate): %s", state)
|
|
|
|
|
return m, nil
|
|
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
case "v", "V":
|
|
|
|
|
// Toggle voice (PTT)
|
|
|
|
|
m.isPTT = !m.isPTT
|
|
|
|
|
if m.isPTT {
|
|
|
|
|
if m.audioCapturer != nil {
|
|
|
|
|
m.audioCapturer.Start()
|
|
|
|
|
}
|
|
|
|
|
m.addLog("🎤 Transmitting...")
|
|
|
|
|
} else {
|
|
|
|
|
if m.audioCapturer != nil {
|
|
|
|
|
m.audioCapturer.Stop()
|
|
|
|
|
}
|
|
|
|
|
m.addLog("🎤 Stopped transmitting")
|
|
|
|
|
}
|
|
|
|
|
return m, nil
|
2026-01-16 22:33:35 +01:00
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
case "l", "L":
|
|
|
|
|
// Toggle Log/Chat view
|
|
|
|
|
m.showLog = !m.showLog
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Focus-specific keys
|
|
|
|
|
switch m.focus {
|
|
|
|
|
case FocusChannels:
|
|
|
|
|
return m.handleChannelKeys(msg)
|
|
|
|
|
case FocusInput:
|
|
|
|
|
return m.handleInputKeys(msg)
|
2026-01-16 16:02:17 +01:00
|
|
|
case FocusChat:
|
|
|
|
|
return m.handleChatKeys(msg)
|
2026-01-17 00:19:49 +01:00
|
|
|
case FocusUserView:
|
|
|
|
|
return m.handleUserViewKeys(msg)
|
2026-01-17 02:01:49 +01:00
|
|
|
case FocusAbout:
|
|
|
|
|
return m.handleAboutViewKeys(msg)
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
2026-01-16 16:02:17 +01:00
|
|
|
return m, nil
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
|
2026-01-17 00:53:50 +01:00
|
|
|
func (m *Model) handleChatKeys(_ tea.KeyMsg) (tea.Model, tea.Cmd) {
|
2026-01-16 14:41:26 +01:00
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 02:01:49 +01:00
|
|
|
func (m *Model) handleAboutViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
|
|
|
if msg.String() == "esc" || msg.String() == "f1" {
|
|
|
|
|
m.focus = m.lastFocus
|
|
|
|
|
}
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
func (m *Model) handleUserViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
|
|
|
if m.viewUser == nil || m.audioPlayer == nil {
|
|
|
|
|
if msg.String() == "esc" || msg.String() == "q" {
|
|
|
|
|
m.focus = FocusChannels
|
|
|
|
|
m.showUserView = false
|
|
|
|
|
}
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
u := m.viewUser
|
|
|
|
|
|
|
|
|
|
switch msg.String() {
|
|
|
|
|
case "esc", "q":
|
|
|
|
|
m.focus = FocusChannels
|
|
|
|
|
m.showUserView = false
|
|
|
|
|
|
2026-01-17 00:25:42 +01:00
|
|
|
case "1":
|
|
|
|
|
// Initiate Poke: set target, clear input, and focus it
|
|
|
|
|
m.pokeID = u.ID
|
|
|
|
|
m.focus = FocusInput
|
|
|
|
|
m.inputText = ""
|
|
|
|
|
m.addLog("Write poke message for %s and press Enter...", u.Nickname)
|
|
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
case "2":
|
|
|
|
|
// Toggle mute for this user
|
|
|
|
|
_, muted := m.audioPlayer.GetUserSettings(u.ID)
|
|
|
|
|
m.audioPlayer.SetUserMuted(u.ID, !muted)
|
|
|
|
|
m.addLog("Toggled mute for %s. Now muted: %v", u.Nickname, !muted)
|
|
|
|
|
|
|
|
|
|
case "+", "=":
|
|
|
|
|
// Increase volume
|
|
|
|
|
currentVol, _ := m.audioPlayer.GetUserSettings(u.ID)
|
|
|
|
|
newVol := currentVol + 0.1
|
|
|
|
|
if newVol > 2.0 {
|
|
|
|
|
newVol = 2.0
|
|
|
|
|
} // Allow up to 200% boost
|
|
|
|
|
m.audioPlayer.SetUserVolume(u.ID, newVol)
|
|
|
|
|
|
|
|
|
|
case "-", "_":
|
|
|
|
|
// Decrease volume
|
|
|
|
|
currentVol, _ := m.audioPlayer.GetUserSettings(u.ID)
|
|
|
|
|
newVol := currentVol - 0.1
|
|
|
|
|
if newVol < 0.0 {
|
|
|
|
|
newVol = 0.0
|
|
|
|
|
}
|
|
|
|
|
m.audioPlayer.SetUserVolume(u.ID, newVol)
|
|
|
|
|
}
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
func (m *Model) handleChannelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
|
|
|
switch msg.String() {
|
|
|
|
|
case "up", "k":
|
2026-01-17 00:58:55 +01:00
|
|
|
for m.selectedIdx > 0 {
|
2026-01-16 14:41:26 +01:00
|
|
|
m.selectedIdx--
|
2026-01-17 00:58:55 +01:00
|
|
|
if !m.items[m.selectedIdx].IsSpacer() {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
// If we hit the top and it's a spacer, we might need to go down to find the first valid one
|
|
|
|
|
if m.selectedIdx == 0 && m.items[m.selectedIdx].IsSpacer() {
|
|
|
|
|
// Search forward for the first valid one
|
|
|
|
|
for i := 0; i < len(m.items); i++ {
|
|
|
|
|
if !m.items[i].IsSpacer() {
|
|
|
|
|
m.selectedIdx = i
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
case "down", "j":
|
2026-01-17 00:58:55 +01:00
|
|
|
for m.selectedIdx < len(m.items)-1 {
|
2026-01-16 14:41:26 +01:00
|
|
|
m.selectedIdx++
|
2026-01-17 00:58:55 +01:00
|
|
|
if !m.items[m.selectedIdx].IsSpacer() {
|
|
|
|
|
break
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
case "enter":
|
2026-01-17 00:19:49 +01:00
|
|
|
// Join selected channel OR open user view
|
|
|
|
|
if m.selectedIdx < len(m.items) && m.client != nil {
|
|
|
|
|
item := m.items[m.selectedIdx]
|
2026-01-17 00:58:55 +01:00
|
|
|
if item.IsSpacer() {
|
|
|
|
|
return m, nil // Do nothing for spacers
|
|
|
|
|
}
|
2026-01-17 00:19:49 +01:00
|
|
|
if !item.IsUser {
|
|
|
|
|
// Channel
|
|
|
|
|
ch := item.Channel
|
|
|
|
|
m.addLog("Attempting to join channel: %s (ID=%d)", ch.Name, ch.ID)
|
|
|
|
|
err := m.client.JoinChannel(ch.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
m.addLog("Error joining channel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// User
|
|
|
|
|
m.viewUser = item.User
|
|
|
|
|
m.showUserView = true
|
|
|
|
|
m.focus = FocusUserView
|
2026-01-16 16:02:17 +01:00
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-17 00:19:49 +01:00
|
|
|
// Bound check
|
|
|
|
|
if m.selectedIdx >= len(m.items) && len(m.items) > 0 {
|
|
|
|
|
m.selectedIdx = len(m.items) - 1
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Model) handleInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
|
|
|
switch msg.String() {
|
|
|
|
|
case "enter":
|
|
|
|
|
if m.inputText != "" && m.client != nil {
|
2026-01-17 00:25:42 +01:00
|
|
|
if m.pokeID != 0 {
|
|
|
|
|
err := m.client.Poke(m.pokeID, m.inputText)
|
|
|
|
|
if err != nil {
|
|
|
|
|
m.addLog("Error poking client %d: %v", m.pokeID, err)
|
|
|
|
|
} else {
|
|
|
|
|
m.addLog("Poke sent!")
|
|
|
|
|
}
|
|
|
|
|
m.pokeID = 0
|
|
|
|
|
m.focus = FocusUserView
|
|
|
|
|
} else {
|
|
|
|
|
m.client.SendChannelMessage(m.inputText)
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
m.inputText = ""
|
|
|
|
|
}
|
|
|
|
|
case "esc":
|
2026-01-17 00:25:42 +01:00
|
|
|
if m.pokeID != 0 {
|
|
|
|
|
m.pokeID = 0
|
|
|
|
|
m.focus = FocusUserView
|
|
|
|
|
} else {
|
|
|
|
|
m.focus = FocusChannels
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
m.inputText = ""
|
|
|
|
|
case "backspace":
|
|
|
|
|
if len(m.inputText) > 0 {
|
2026-01-16 22:41:26 +01:00
|
|
|
// Handle UTF-8 backspace properly
|
|
|
|
|
runes := []rune(m.inputText)
|
|
|
|
|
if len(runes) > 0 {
|
|
|
|
|
m.inputText = string(runes[:len(runes)-1])
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
// Add character to input
|
2026-01-16 22:41:26 +01:00
|
|
|
// Allow Runes (including multi-byte like ñ) and Space
|
|
|
|
|
// Filter out special keys that might send description strings (like "alt+") by ensuring only 1 rune
|
|
|
|
|
if msg.Type == tea.KeyRunes {
|
|
|
|
|
runes := []rune(msg.String())
|
|
|
|
|
if len(runes) == 1 {
|
|
|
|
|
m.inputText += string(runes)
|
|
|
|
|
}
|
|
|
|
|
} else if msg.Type == tea.KeySpace {
|
|
|
|
|
m.inputText += " "
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// View renders the UI
|
|
|
|
|
func (m *Model) View() string {
|
2026-01-17 15:57:34 +01:00
|
|
|
if m.showPokePopup {
|
|
|
|
|
boxStyle := lipgloss.NewStyle().
|
|
|
|
|
Border(lipgloss.DoubleBorder()).
|
|
|
|
|
BorderForeground(lipgloss.Color("196")). // Red for Poke
|
|
|
|
|
Padding(1, 2).
|
|
|
|
|
Width(50).
|
|
|
|
|
Align(lipgloss.Center)
|
|
|
|
|
|
|
|
|
|
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("226")).MarginBottom(1)
|
|
|
|
|
senderStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Bold(true)
|
|
|
|
|
msgStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Italic(true)
|
|
|
|
|
helpStyle := lipgloss.NewStyle().Faint(true).MarginTop(2)
|
|
|
|
|
|
|
|
|
|
content := lipgloss.JoinVertical(lipgloss.Center,
|
|
|
|
|
titleStyle.Render("YOU WERE POKED!"),
|
|
|
|
|
"",
|
|
|
|
|
fmt.Sprintf("From: %s", senderStyle.Render(m.pokePopupSender)),
|
|
|
|
|
"",
|
|
|
|
|
msgStyle.Render(fmt.Sprintf("%q", m.pokePopupMessage)),
|
|
|
|
|
"",
|
|
|
|
|
helpStyle.Render("(Press Esc/Enter to close)"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, boxStyle.Render(content))
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 02:01:49 +01:00
|
|
|
if m.focus == FocusAbout {
|
|
|
|
|
return m.renderAboutView()
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
if m.width == 0 {
|
|
|
|
|
return "Loading..."
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Styles
|
2026-01-16 22:11:58 +01:00
|
|
|
// 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
|
2026-01-16 14:41:26 +01:00
|
|
|
|
2026-01-16 22:52:10 +01:00
|
|
|
// Calculate explicit widths to fit exactly (Width is content width)
|
|
|
|
|
// Box Width = Content + 2 (Border) + 2 (Padding) = Content + 4
|
|
|
|
|
// We want LeftBox + RightBox = m.width
|
|
|
|
|
leftBoxWidth := m.width / 4
|
|
|
|
|
rightBoxWidth := m.width - leftBoxWidth
|
|
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
channelPanelStyle := lipgloss.NewStyle().
|
|
|
|
|
Border(lipgloss.RoundedBorder()).
|
|
|
|
|
BorderForeground(lipgloss.Color("63")).
|
|
|
|
|
Padding(0, 1).
|
2026-01-16 22:52:10 +01:00
|
|
|
Width(leftBoxWidth - 4).
|
2026-01-16 22:11:58 +01:00
|
|
|
Height(panelHeight)
|
2026-01-16 14:41:26 +01:00
|
|
|
|
|
|
|
|
chatPanelStyle := lipgloss.NewStyle().
|
|
|
|
|
Border(lipgloss.RoundedBorder()).
|
|
|
|
|
BorderForeground(lipgloss.Color("63")).
|
|
|
|
|
Padding(0, 1).
|
2026-01-17 00:03:54 +01:00
|
|
|
Width(rightBoxWidth).
|
2026-01-16 22:11:58 +01:00
|
|
|
Height(panelHeight)
|
2026-01-16 14:41:26 +01:00
|
|
|
|
|
|
|
|
inputStyle := lipgloss.NewStyle().
|
|
|
|
|
Border(lipgloss.NormalBorder()).
|
|
|
|
|
BorderForeground(lipgloss.Color("63")).
|
|
|
|
|
Padding(0, 1).
|
2026-01-17 00:03:54 +01:00
|
|
|
Width(m.width - 2)
|
2026-01-16 14:41:26 +01:00
|
|
|
|
2026-01-16 22:11:58 +01:00
|
|
|
// Header with status bar
|
|
|
|
|
header := m.renderStatusBar()
|
2026-01-16 14:41:26 +01:00
|
|
|
|
|
|
|
|
// Channel panel
|
|
|
|
|
channelContent := m.renderChannels()
|
|
|
|
|
if m.focus == FocusChannels {
|
|
|
|
|
channelPanelStyle = channelPanelStyle.BorderForeground(lipgloss.Color("212"))
|
|
|
|
|
}
|
|
|
|
|
channelPanel := channelPanelStyle.Render(channelContent)
|
|
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
// Right panel (Chat, Log, or User View)
|
2026-01-16 22:33:35 +01:00
|
|
|
var rightPanel string
|
|
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
if m.showUserView && m.viewUser != nil {
|
|
|
|
|
// User View
|
|
|
|
|
userViewContent := m.renderUserView()
|
|
|
|
|
userViewStyle := chatPanelStyle.Copy().BorderForeground(lipgloss.Color("208")) // Orange focus
|
|
|
|
|
rightPanel = userViewStyle.Render(userViewContent)
|
|
|
|
|
} else if m.showLog {
|
2026-01-16 22:33:35 +01:00
|
|
|
// Log View
|
|
|
|
|
// Use chatPanelStyle default but add focus logic
|
|
|
|
|
logStyle := chatPanelStyle.Copy()
|
|
|
|
|
|
|
|
|
|
if m.focus == FocusChat {
|
|
|
|
|
logStyle = logStyle.BorderForeground(lipgloss.Color("212"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Limit to last N messages log to fit panel
|
|
|
|
|
// Panel height is m.height - 7, padding reduces it more
|
|
|
|
|
maxLines := m.height - 11
|
|
|
|
|
if maxLines < 1 {
|
|
|
|
|
maxLines = 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
start := 0
|
|
|
|
|
if len(m.logMessages) > maxLines {
|
|
|
|
|
start = len(m.logMessages) - maxLines
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate available width for text
|
2026-01-16 22:52:10 +01:00
|
|
|
textWidth := (rightBoxWidth - 4) - 2 // Content width - margin
|
2026-01-16 22:33:35 +01:00
|
|
|
if textWidth < 10 {
|
|
|
|
|
textWidth = 10
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
|
|
|
|
var lines []string
|
|
|
|
|
|
|
|
|
|
for _, msg := range m.logMessages[start:] {
|
|
|
|
|
// Sanitize: remove newlines/returns to prevent double spacing
|
|
|
|
|
msg = strings.ReplaceAll(msg, "\n", " ")
|
|
|
|
|
msg = strings.ReplaceAll(msg, "\r", "")
|
|
|
|
|
|
|
|
|
|
// Truncate simplisticly to prevent wrapping issues if too long
|
|
|
|
|
if len(msg) > textWidth {
|
|
|
|
|
msg = msg[:textWidth-3] + "..."
|
|
|
|
|
}
|
|
|
|
|
lines = append(lines, textStyle.Render(msg))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rightPanel = logStyle.Render(strings.Join(lines, "\n"))
|
|
|
|
|
} else {
|
|
|
|
|
// Chat View
|
|
|
|
|
chatContent := m.renderChat()
|
|
|
|
|
if m.focus == FocusChat {
|
|
|
|
|
chatPanelStyle = chatPanelStyle.BorderForeground(lipgloss.Color("212"))
|
|
|
|
|
}
|
|
|
|
|
rightPanel = chatPanelStyle.Render(chatContent)
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Input
|
2026-01-17 00:25:42 +01:00
|
|
|
prompt := "> "
|
|
|
|
|
if m.pokeID != 0 {
|
|
|
|
|
prompt = "[Poke Message] > "
|
|
|
|
|
}
|
|
|
|
|
inputContent := prompt + m.inputText
|
2026-01-16 14:41:26 +01:00
|
|
|
if m.focus == FocusInput {
|
|
|
|
|
inputStyle = inputStyle.BorderForeground(lipgloss.Color("212"))
|
|
|
|
|
inputContent += "█"
|
|
|
|
|
}
|
|
|
|
|
input := inputStyle.Render(inputContent)
|
|
|
|
|
|
|
|
|
|
// Footer help
|
2026-01-16 22:33:35 +01:00
|
|
|
logHelp := "L log"
|
|
|
|
|
if m.showLog {
|
|
|
|
|
logHelp = "L chat"
|
|
|
|
|
}
|
|
|
|
|
help := lipgloss.NewStyle().Faint(true).Render(fmt.Sprintf("↑↓ navigate │ Enter join │ Tab switch │ %s │ V talk │ M mute │ +/- vol │ q quit", logHelp))
|
2026-01-16 14:41:26 +01:00
|
|
|
|
|
|
|
|
// Combine panels
|
2026-01-16 22:33:35 +01:00
|
|
|
panels := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, rightPanel)
|
2026-01-16 14:41:26 +01:00
|
|
|
|
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left,
|
|
|
|
|
header,
|
|
|
|
|
panels,
|
|
|
|
|
input,
|
|
|
|
|
help,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 22:11:58 +01:00
|
|
|
// 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)
|
|
|
|
|
|
2026-01-17 17:02:18 +01:00
|
|
|
// Custom Mic Bar with VAD Threshold
|
|
|
|
|
micBarWidth := 8
|
|
|
|
|
var micBar string
|
|
|
|
|
|
|
|
|
|
if m.vadEnabled {
|
|
|
|
|
// Calculate threshold position
|
|
|
|
|
threshPos := m.vadThreshold * micBarWidth / 100
|
|
|
|
|
if threshPos >= micBarWidth {
|
|
|
|
|
threshPos = micBarWidth - 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate filled position based on current level
|
|
|
|
|
filled := m.micLevel * micBarWidth / 100
|
|
|
|
|
|
|
|
|
|
// Build bar
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
for i := 0; i < micBarWidth; i++ {
|
|
|
|
|
char := "░"
|
|
|
|
|
if i < filled {
|
|
|
|
|
char = "█"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Overlay threshold marker
|
|
|
|
|
if i == threshPos {
|
|
|
|
|
if i < filled {
|
|
|
|
|
// Threshold is met
|
|
|
|
|
char = "▓"
|
|
|
|
|
} else {
|
|
|
|
|
// Threshold not met
|
|
|
|
|
char = "│"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sb.WriteString(char)
|
|
|
|
|
}
|
|
|
|
|
micBar = sb.String()
|
|
|
|
|
} else {
|
|
|
|
|
micBar = audio.LevelToBar(m.micLevel, micBarWidth)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pttStyle := lipgloss.NewStyle()
|
2026-01-16 22:11:58 +01:00
|
|
|
pttIcon := "MIC"
|
2026-01-17 17:02:18 +01:00
|
|
|
|
2026-01-16 22:11:58 +01:00
|
|
|
if m.isPTT {
|
2026-01-17 17:02:18 +01:00
|
|
|
pttIcon = " ON" // Manual ON
|
|
|
|
|
pttStyle = pttStyle.Foreground(lipgloss.Color("196")).Bold(true) // Red
|
|
|
|
|
} else if m.vadEnabled {
|
|
|
|
|
pttIcon = "VAD"
|
|
|
|
|
// Check if actively transmitting (using logic with decay)
|
|
|
|
|
isTransmitting := false
|
|
|
|
|
if !m.isMuted {
|
|
|
|
|
if m.micLevel > m.vadThreshold {
|
|
|
|
|
isTransmitting = true
|
|
|
|
|
} else if !m.vadLastTriggered.IsZero() && time.Since(m.vadLastTriggered) < 1*time.Second {
|
|
|
|
|
isTransmitting = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if isTransmitting {
|
|
|
|
|
// Transmitting via VAD: Red/Bold
|
|
|
|
|
pttStyle = pttStyle.Foreground(lipgloss.Color("196")).Bold(true)
|
|
|
|
|
} else {
|
|
|
|
|
// Idle VAD: Gray/Faint
|
|
|
|
|
pttStyle = pttStyle.Foreground(lipgloss.Color("240")).Faint(true)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Standard Mic (PTT mode but off)
|
|
|
|
|
pttStyle = pttStyle.Foreground(lipgloss.Color("255")) // White
|
2026-01-16 22:11:58 +01:00
|
|
|
}
|
2026-01-17 17:02:18 +01:00
|
|
|
|
|
|
|
|
// Apply status bar background color to prevent cutting
|
|
|
|
|
pttStyle = pttStyle.Background(lipgloss.Color("57")) // Matches Top Bar Background
|
|
|
|
|
|
|
|
|
|
// Style for the bar itself to maintain background continuity
|
|
|
|
|
barStyle := lipgloss.NewStyle().Background(lipgloss.Color("57")).Foreground(lipgloss.Color("255"))
|
|
|
|
|
|
|
|
|
|
micPart := fmt.Sprintf("%s%s%s",
|
|
|
|
|
pttStyle.Render(pttIcon),
|
|
|
|
|
barStyle.Render(":"),
|
|
|
|
|
barStyle.Render(micBar))
|
2026-01-16 22:11:58 +01:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 23:54:36 +01:00
|
|
|
// Regex for TeamSpeak spacers: [spacer0], [cspacer], [*spacer], etc.
|
|
|
|
|
var spacerRegex = regexp.MustCompile(`^\[([*cZr]?spacer[\w\d]*)\](.*)`)
|
|
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
func (m *Model) renderChannels() string {
|
2026-01-17 00:19:49 +01:00
|
|
|
if len(m.items) == 0 {
|
2026-01-16 14:41:26 +01:00
|
|
|
return "No channels..."
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var lines []string
|
|
|
|
|
lines = append(lines, lipgloss.NewStyle().Bold(true).Render("CHANNELS"))
|
|
|
|
|
lines = append(lines, "")
|
|
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
for i, item := range m.items {
|
|
|
|
|
// If selected
|
|
|
|
|
isSelected := (i == m.selectedIdx)
|
|
|
|
|
|
|
|
|
|
if !item.IsUser {
|
|
|
|
|
// CHANNEL
|
|
|
|
|
ch := item.Channel
|
|
|
|
|
indent := strings.Repeat(" ", ch.Depth)
|
|
|
|
|
prefix := indent + " "
|
|
|
|
|
if isSelected {
|
|
|
|
|
prefix = indent + "► "
|
|
|
|
|
}
|
2026-01-16 23:54:36 +01:00
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
style := lipgloss.NewStyle()
|
|
|
|
|
if isSelected {
|
|
|
|
|
style = style.Bold(true).Foreground(lipgloss.Color("212"))
|
2026-01-16 23:54:36 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
displayName := ch.Name
|
|
|
|
|
|
|
|
|
|
// Spacer rendering logic
|
|
|
|
|
matches := spacerRegex.FindStringSubmatch(ch.Name)
|
|
|
|
|
if len(matches) > 0 {
|
|
|
|
|
tag := matches[1]
|
|
|
|
|
content := matches[2]
|
|
|
|
|
width := (m.width / 4) - 6
|
|
|
|
|
if width < 0 {
|
|
|
|
|
width = 0
|
2026-01-16 23:54:36 +01:00
|
|
|
}
|
2026-01-17 00:19:49 +01:00
|
|
|
|
|
|
|
|
if strings.HasPrefix(tag, "*") {
|
|
|
|
|
if len(content) > 0 {
|
|
|
|
|
count := width / len(content)
|
|
|
|
|
if count > 0 {
|
|
|
|
|
displayName = strings.Repeat(content, count+1)[:width]
|
|
|
|
|
}
|
2026-01-16 23:54:36 +01:00
|
|
|
} else {
|
2026-01-17 00:19:49 +01:00
|
|
|
displayName = strings.Repeat("-", width)
|
2026-01-16 23:54:36 +01:00
|
|
|
}
|
2026-01-17 00:19:49 +01:00
|
|
|
} else if strings.HasPrefix(tag, "c") {
|
2026-01-16 23:54:36 +01:00
|
|
|
gap := (width - len(content)) / 2
|
|
|
|
|
if gap > 0 {
|
|
|
|
|
displayName = strings.Repeat(" ", gap) + content
|
|
|
|
|
} else {
|
|
|
|
|
displayName = content
|
|
|
|
|
}
|
2026-01-17 00:19:49 +01:00
|
|
|
} else {
|
|
|
|
|
displayName = content
|
2026-01-16 23:54:36 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
lines = append(lines, style.Render(prefix+displayName))
|
|
|
|
|
} else {
|
|
|
|
|
// USER
|
|
|
|
|
user := item.User
|
|
|
|
|
ch := item.Channel // Parent
|
|
|
|
|
indent := strings.Repeat(" ", ch.Depth)
|
|
|
|
|
|
|
|
|
|
prefix := indent + " └─ "
|
|
|
|
|
if isSelected {
|
|
|
|
|
prefix = indent + " => "
|
|
|
|
|
}
|
2026-01-16 14:41:26 +01:00
|
|
|
|
|
|
|
|
userStyle := lipgloss.NewStyle().Faint(true)
|
2026-01-16 19:50:44 +01:00
|
|
|
|
|
|
|
|
if user.Talking {
|
2026-01-17 00:19:49 +01:00
|
|
|
userStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("46"))
|
|
|
|
|
prefix = indent + " 🔊 "
|
2026-01-16 19:50:44 +01:00
|
|
|
} else if user.IsMe {
|
2026-01-16 14:41:26 +01:00
|
|
|
userStyle = userStyle.Foreground(lipgloss.Color("82"))
|
|
|
|
|
}
|
2026-01-16 19:50:44 +01:00
|
|
|
|
2026-01-17 00:19:49 +01:00
|
|
|
if isSelected {
|
|
|
|
|
userStyle = userStyle.Bold(true).Foreground(lipgloss.Color("212")) // Pink selection
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lines = append(lines, userStyle.Render(prefix+user.Nickname))
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Model) renderChat() string {
|
|
|
|
|
var lines []string
|
|
|
|
|
|
2026-01-16 22:33:35 +01:00
|
|
|
if len(m.chatMessages) == 0 {
|
|
|
|
|
lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No chat messages yet..."))
|
2026-01-16 14:41:26 +01:00
|
|
|
} else {
|
2026-01-16 22:33:35 +01:00
|
|
|
// Limit messages to fit panel
|
|
|
|
|
// Panel height is roughly m.height - 7
|
|
|
|
|
maxLines := m.height - 9
|
|
|
|
|
if maxLines < 1 {
|
|
|
|
|
maxLines = 1
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
2026-01-16 16:02:17 +01:00
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
start := 0
|
2026-01-16 22:33:35 +01:00
|
|
|
if len(m.chatMessages) > maxLines {
|
|
|
|
|
start = len(m.chatMessages) - maxLines
|
2026-01-16 16:02:17 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-16 22:33:35 +01:00
|
|
|
for _, msg := range m.chatMessages[start:] {
|
|
|
|
|
prefix := msg.Time.Format("15:04") + " " + msg.Sender + ": "
|
|
|
|
|
content := msg.Content
|
2026-01-16 16:02:17 +01:00
|
|
|
|
2026-01-16 22:33:35 +01:00
|
|
|
// Simple wrapping logic could go here, but for now simple truncation/display
|
|
|
|
|
line := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(prefix) + content
|
|
|
|
|
lines = append(lines, line)
|
2026-01-16 14:41:26 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 22:33:35 +01:00
|
|
|
// Fill remaining lines with empty space to maintain stable layout
|
|
|
|
|
for len(lines) < m.height-9 {
|
|
|
|
|
lines = append(lines, "")
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 14:41:26 +01:00
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
|
|
|
}
|
2026-01-17 00:19:49 +01:00
|
|
|
|
|
|
|
|
func (m *Model) renderUserView() string {
|
|
|
|
|
if m.viewUser == nil {
|
|
|
|
|
return "No user selected"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
u := m.viewUser
|
|
|
|
|
|
|
|
|
|
// Get audio settings
|
|
|
|
|
vol := float32(1.0)
|
|
|
|
|
muted := false
|
|
|
|
|
if m.audioPlayer != nil {
|
|
|
|
|
vol, muted = m.audioPlayer.GetUserSettings(u.ID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("208")).Underline(true)
|
|
|
|
|
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
|
|
|
|
valStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
|
|
|
|
|
|
|
|
|
|
muteStr := "No"
|
|
|
|
|
if muted {
|
|
|
|
|
muteStr = "YES"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Info section
|
|
|
|
|
info := []string{
|
|
|
|
|
titleStyle.Render(fmt.Sprintf("User: %s", u.Nickname)),
|
|
|
|
|
"",
|
|
|
|
|
fmt.Sprintf("%s %s", labelStyle.Render("ID:"), valStyle.Render(fmt.Sprintf("%d", u.ID))),
|
|
|
|
|
fmt.Sprintf("%s %v", labelStyle.Render("Talking:"), valStyle.Render(fmt.Sprintf("%v", m.talkingClients[u.ID]))),
|
|
|
|
|
fmt.Sprintf("%s %v", labelStyle.Render("Muted (Server):"), valStyle.Render(fmt.Sprintf("%v", u.Muted))),
|
|
|
|
|
"",
|
|
|
|
|
"--- Audio Settings ---",
|
|
|
|
|
fmt.Sprintf("%s %d%%", labelStyle.Render("Volume:"), int(vol*100)),
|
|
|
|
|
fmt.Sprintf("%s %s", labelStyle.Render("Local Mute:"), muteStr),
|
|
|
|
|
"",
|
|
|
|
|
"--- Menu ---",
|
2026-01-17 00:25:42 +01:00
|
|
|
"1. Poke",
|
2026-01-17 00:19:49 +01:00
|
|
|
"2. Toggle Local Mute",
|
|
|
|
|
"+/-: Adjust Volume",
|
|
|
|
|
"",
|
|
|
|
|
"(Press ESC to close)",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, info...)
|
|
|
|
|
}
|
2026-01-17 02:01:49 +01:00
|
|
|
|
|
|
|
|
func (m *Model) renderAboutView() string {
|
|
|
|
|
titleStyle := lipgloss.NewStyle().
|
|
|
|
|
Bold(true).
|
|
|
|
|
Foreground(lipgloss.Color("205")).
|
|
|
|
|
MarginBottom(1).
|
|
|
|
|
Align(lipgloss.Center)
|
|
|
|
|
|
|
|
|
|
boxStyle := lipgloss.NewStyle().
|
|
|
|
|
Border(lipgloss.RoundedBorder()).
|
|
|
|
|
BorderForeground(lipgloss.Color("62")).
|
|
|
|
|
Padding(2).
|
|
|
|
|
Width(60).
|
|
|
|
|
Align(lipgloss.Center)
|
|
|
|
|
|
|
|
|
|
mainContent := lipgloss.JoinVertical(lipgloss.Center,
|
|
|
|
|
titleStyle.Render("TS3 TUI CLIENT"),
|
|
|
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Render("Una terminal potente para tus comunidades."),
|
|
|
|
|
"",
|
2026-01-17 03:27:18 +01:00
|
|
|
lipgloss.NewStyle().Bold(true).Render("Realizado por JosLeDeta"),
|
|
|
|
|
lipgloss.NewStyle().Italic(true).Faint(true).Render("Hecho en Antigravity"),
|
2026-01-17 02:01:49 +01:00
|
|
|
"",
|
|
|
|
|
lipgloss.NewStyle().Bold(true).Render("Con la ayuda de:"),
|
|
|
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Render("- Gemini 3 Pro"),
|
|
|
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Render("- Claude Opus 4.5"),
|
|
|
|
|
"",
|
|
|
|
|
"",
|
|
|
|
|
lipgloss.NewStyle().Faint(true).Render("(Presiona ESC o F1 para volver)"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Center everything on screen
|
|
|
|
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, boxStyle.Render(mainContent))
|
|
|
|
|
}
|