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-17 20:25:58 +01:00
// Interactive EQ
eqBandIdx int // 0-4
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"
2026-01-17 17:10:21 +01:00
// Ensure capturer is running if VAD is on
if m . audioCapturer != nil && ! m . audioCapturer . IsRunning ( ) {
if err := m . audioCapturer . Start ( ) ; err != nil {
m . addLog ( "Error starting VAD capture: %v" , err )
}
}
} else {
// Stop if PTT is also off
if ! m . isPTT && m . audioCapturer != nil && m . audioCapturer . IsRunning ( ) {
m . audioCapturer . Stop ( )
}
2026-01-17 17:02:18 +01:00
}
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 {
2026-01-17 17:10:21 +01:00
if m . audioCapturer != nil && ! m . audioCapturer . IsRunning ( ) {
if err := m . audioCapturer . Start ( ) ; err != nil {
m . addLog ( "Audio capture error: %v" , err )
}
2026-01-17 00:19:49 +01:00
}
m . addLog ( "🎤 Transmitting..." )
} else {
2026-01-17 17:10:21 +01:00
// Stop only if VAD is also off
if ! m . vadEnabled && m . audioCapturer != nil && m . audioCapturer . IsRunning ( ) {
2026-01-17 00:19:49 +01:00
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 )
2026-01-17 20:25:58 +01:00
case "right" , "l" :
// Increase Gain for selected band
current := m . audioPlayer . GetUserGain ( u . ID , m . eqBandIdx )
m . audioPlayer . SetUserGain ( u . ID , m . eqBandIdx , current + 1.0 )
case "left" , "h" :
// Decrease Gain
current := m . audioPlayer . GetUserGain ( u . ID , m . eqBandIdx )
m . audioPlayer . SetUserGain ( u . ID , m . eqBandIdx , current - 1.0 )
case "up" , "k" :
m . eqBandIdx --
if m . eqBandIdx < 0 {
m . eqBandIdx = 4
}
case "down" , "j" :
m . eqBandIdx ++
if m . eqBandIdx > 4 {
m . eqBandIdx = 0
}
2026-01-17 00:19:49 +01:00
}
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"
}
2026-01-17 17:08:25 +01:00
help := lipgloss . NewStyle ( ) . Faint ( true ) . Render ( fmt . Sprintf ( "↑↓ nav │ Ent join │ Tab switch │ %s │ V PTT │ G VAD │ ^↕↔ thresh │ 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 ) ,
2026-01-17 20:25:58 +01:00
}
// EQ Visualization
var eqGraph [ ] string
if m . audioPlayer != nil {
bands := m . audioPlayer . GetEQBands ( u . ID )
if len ( bands ) > 0 {
eqGraph = append ( eqGraph , "" , "--- Interactive Equalizer ---" , "" )
// Render bars for 5 bands
// 0: Bass, 1: Low-Mid, 2: Mid, 3: High-Mid, 4: High
labels := [ ] string { "100Hz" , "350Hz" , "1kHz" , "3kHz" , "8kHz" }
for i , val := range bands {
if i >= len ( labels ) {
break
}
// Get current gain setting
gain := m . audioPlayer . GetUserGain ( u . ID , i )
// Scale 0.0-1.0 to bars (width 20)
const maxBars = 20
bars := int ( val * maxBars )
if bars > maxBars {
bars = maxBars
}
// Bar characters: █ ▇ ▆ ▅ ▄ ▃ ▂
barStr := ""
if bars > 0 {
barStr = strings . Repeat ( "█" , bars )
}
// Colorize based on intensity
barStyle := lipgloss . NewStyle ( ) . Foreground ( lipgloss . Color ( "39" ) ) // Blue default
if val > 0.8 {
barStyle = barStyle . Foreground ( lipgloss . Color ( "196" ) ) // Red clipping
} else if val > 0.5 {
barStyle = barStyle . Foreground ( lipgloss . Color ( "208" ) ) // Orange high
} else if val > 0.2 {
barStyle = barStyle . Foreground ( lipgloss . Color ( "46" ) ) // Green normal
}
// Selection Indicator
selector := " "
labelStyle := lipgloss . NewStyle ( ) . Foreground ( lipgloss . Color ( "240" ) ) . Width ( 6 )
gainStyle := lipgloss . NewStyle ( ) . Foreground ( lipgloss . Color ( "245" ) ) . Width ( 6 )
if i == m . eqBandIdx {
selector = "->"
labelStyle = labelStyle . Foreground ( lipgloss . Color ( "226" ) ) . Bold ( true ) // Yellow for selected
gainStyle = gainStyle . Foreground ( lipgloss . Color ( "226" ) )
}
gainStr := fmt . Sprintf ( "%+3.0fdB" , gain )
line := fmt . Sprintf ( "%s %s %s | %s" ,
selector ,
labelStyle . Render ( labels [ i ] ) ,
gainStyle . Render ( gainStr ) ,
barStyle . Render ( fmt . Sprintf ( "%-20s" , barStr ) ) )
eqGraph = append ( eqGraph , line )
}
}
}
info = append ( info , eqGraph ... )
info = append ( info ,
2026-01-17 00:19:49 +01:00
"" ,
"--- 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" ,
2026-01-17 20:25:58 +01:00
"Arrows: Adjust EQ" ,
2026-01-17 00:19:49 +01:00
"" ,
"(Press ESC to close)" ,
2026-01-17 20:25:58 +01:00
)
2026-01-17 00:19:49 +01:00
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 ) )
}