Implement per-user audio control (vol/mute) and User View UI

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-17 00:19:49 +01:00
parent 0b6399ed61
commit 5b8e89e9a2
2 changed files with 321 additions and 132 deletions

View File

@@ -21,8 +21,16 @@ const (
FocusChannels Focus = iota
FocusChat
FocusInput
FocusUserView
)
// ListItem represents an item in the navigation tree (Channel or User)
type ListItem struct {
IsUser bool
Channel *ChannelNode
User *UserNode
}
// ChatMessage represents a message in the chat
type ChatMessage struct {
Time time.Time
@@ -65,6 +73,7 @@ type Model struct {
focus Focus
width, height int
channels []ChannelNode
items []ListItem // Flattened list for navigation
selectedIdx int
chatMessages []ChatMessage
logMessages []string // Debug logs shown in chat panel
@@ -88,6 +97,10 @@ type Model struct {
// Program reference for sending messages from event handlers
program *tea.Program
showLog bool
// User View
showUserView bool
viewUser *UserNode
}
// addLog adds a message to the log panel
@@ -428,6 +441,20 @@ func (m *Model) updateChannelList(channels []*ts3client.Channel) {
// We'll stick to valid tree.
m.channels = sortedNodes
// 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})
}
}
}
}
func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -459,70 +486,72 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// 3. Global Shortcuts (Only when NOT in Input)
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
case "m", "M":
// Toggle mute
m.isMuted = !m.isMuted
if m.audioPlayer != nil {
m.audioPlayer.SetMuted(m.isMuted)
}
return m, nil
case "+", "=":
// Increase volume
m.playbackVol += 10
if m.playbackVol > 100 {
m.playbackVol = 100
}
if m.audioPlayer != nil {
m.audioPlayer.SetVolume(float32(m.playbackVol) / 100.0)
}
return m, nil
case "-", "_":
// Decrease volume
m.playbackVol -= 10
if m.playbackVol < 0 {
m.playbackVol = 0
}
if m.audioPlayer != nil {
m.audioPlayer.SetVolume(float32(m.playbackVol) / 100.0)
}
return m, nil
case "v", "V":
// Toggle voice (PTT)
m.isPTT = !m.isPTT
if m.isPTT {
if m.audioCapturer != nil {
m.audioCapturer.Start()
if m.focus != FocusInput && m.focus != FocusUserView {
switch msg.String() {
case "q":
// Quit (same as ctrl+c)
if m.client != nil {
m.client.Disconnect()
}
m.addLog("🎤 Transmitting...")
} else {
if m.audioCapturer != nil {
m.audioCapturer.Stop()
if m.audioPlayer != nil {
m.audioPlayer.Close()
}
m.addLog("🎤 Stopped transmitting")
}
return m, nil
if m.audioCapturer != nil {
m.audioCapturer.Close()
}
return m, tea.Quit
case "l", "L":
// Toggle Log/Chat view
m.showLog = !m.showLog
return m, nil
case "m", "M":
// Toggle mute
m.isMuted = !m.isMuted
if m.audioPlayer != nil {
m.audioPlayer.SetMuted(m.isMuted)
}
return m, nil
case "+", "=":
// Increase volume
m.playbackVol += 10
if m.playbackVol > 100 {
m.playbackVol = 100
}
if m.audioPlayer != nil {
m.audioPlayer.SetVolume(float32(m.playbackVol) / 100.0)
}
return m, nil
case "-", "_":
// Decrease volume
m.playbackVol -= 10
if m.playbackVol < 0 {
m.playbackVol = 0
}
if m.audioPlayer != nil {
m.audioPlayer.SetVolume(float32(m.playbackVol) / 100.0)
}
return m, nil
case "v", "V":
// Toggle voice (PTT)
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
case "l", "L":
// Toggle Log/Chat view
m.showLog = !m.showLog
return m, nil
}
}
// Focus-specific keys
@@ -533,6 +562,8 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleInputKeys(msg)
case FocusChat:
return m.handleChatKeys(msg)
case FocusUserView:
return m.handleUserViewKeys(msg)
}
return m, nil
}
@@ -541,6 +572,49 @@ func (m *Model) handleChatKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
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
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
}
func (m *Model) handleChannelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
@@ -548,20 +622,33 @@ func (m *Model) handleChannelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.selectedIdx--
}
case "down", "j":
if m.selectedIdx < len(m.channels)-1 {
if m.selectedIdx < len(m.items)-1 {
m.selectedIdx++
}
case "enter":
// Join selected channel
if m.selectedIdx < len(m.channels) && m.client != nil {
ch := m.channels[m.selectedIdx]
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)
// Join selected channel OR open user view
if m.selectedIdx < len(m.items) && m.client != nil {
item := m.items[m.selectedIdx]
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
}
}
}
// Bound check
if m.selectedIdx >= len(m.items) && len(m.items) > 0 {
m.selectedIdx = len(m.items) - 1
}
return m, nil
}
@@ -646,10 +733,15 @@ func (m *Model) View() string {
}
channelPanel := channelPanelStyle.Render(channelContent)
// Right panel (Chat or Log)
// Right panel (Chat, Log, or User View)
var rightPanel string
if m.showLog {
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 {
// Log View
// Use chatPanelStyle default but add focus logic
logStyle := chatPanelStyle.Copy()
@@ -807,7 +899,7 @@ func (m *Model) renderStatusBar() string {
var spacerRegex = regexp.MustCompile(`^\[([*cZr]?spacer[\w\d]*)\](.*)`)
func (m *Model) renderChannels() string {
if len(m.channels) == 0 {
if len(m.items) == 0 {
return "No channels..."
}
@@ -815,46 +907,46 @@ func (m *Model) renderChannels() string {
lines = append(lines, lipgloss.NewStyle().Bold(true).Render("CHANNELS"))
lines = append(lines, "")
for i, ch := range m.channels {
// Indentation based on depth
indent := strings.Repeat(" ", ch.Depth)
prefix := indent + " "
if i == m.selectedIdx {
prefix = indent + "► "
}
for i, item := range m.items {
// If selected
isSelected := (i == m.selectedIdx)
style := lipgloss.NewStyle()
if i == m.selectedIdx {
style = style.Bold(true).Foreground(lipgloss.Color("212"))
}
// Check for spacer
displayName := ch.Name
matches := spacerRegex.FindStringSubmatch(ch.Name)
if len(matches) > 0 {
tag := matches[1] // e.g. "spacer0", "cspacer", "*spacer"
content := matches[2] // e.g. "---", "Section"
// Calculate effective width: width/4 - 6 (border+padding)
width := (m.width / 4) - 6
if width < 0 {
width = 0
if !item.IsUser {
// CHANNEL
ch := item.Channel
indent := strings.Repeat(" ", ch.Depth)
prefix := indent + " "
if isSelected {
prefix = indent + "► "
}
if strings.HasPrefix(tag, "*") {
// Repeat content
if len(content) > 0 {
count := width / len(content)
if count > 0 {
displayName = strings.Repeat(content, count+1)[:width]
}
} else {
displayName = strings.Repeat("-", width)
style := lipgloss.NewStyle()
if isSelected {
style = style.Bold(true).Foreground(lipgloss.Color("212"))
}
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
}
} else if strings.HasPrefix(tag, "c") {
// Center content
if len(content) > len(displayName) { // If parsed content is safer?
// No, just align content
if strings.HasPrefix(tag, "*") {
if len(content) > 0 {
count := width / len(content)
if count > 0 {
displayName = strings.Repeat(content, count+1)[:width]
}
} else {
displayName = strings.Repeat("-", width)
}
} else if strings.HasPrefix(tag, "c") {
gap := (width - len(content)) / 2
if gap > 0 {
displayName = strings.Repeat(" ", gap) + content
@@ -862,38 +954,36 @@ func (m *Model) renderChannels() string {
displayName = content
}
} else {
// Fallback if measurement is tricky, just show content
// Actually, let's try to center strictly
gap := (width - len(content)) / 2
if gap > 0 {
displayName = strings.Repeat(" ", gap) + content
} else {
displayName = content
}
displayName = content
}
} else {
// Standard spacer (left align)
displayName = content
}
}
lines = append(lines, style.Render(prefix+displayName))
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 + " => "
}
// Show users in channel
for _, user := range ch.Users {
userPrefix := " └─ "
userStyle := lipgloss.NewStyle().Faint(true)
// Talking users get bright green color and speaker icon
if user.Talking {
userStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("46")) // Bright green
userPrefix = " └─ 🔊 "
userStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("46"))
prefix = indent + " 🔊 "
} else if user.IsMe {
userStyle = userStyle.Foreground(lipgloss.Color("82"))
userPrefix = " └─ ► "
}
lines = append(lines, userStyle.Render(userPrefix+user.Nickname))
if isSelected {
userStyle = userStyle.Bold(true).Foreground(lipgloss.Color("212")) // Pink selection
}
lines = append(lines, userStyle.Render(prefix+user.Nickname))
}
}
@@ -935,3 +1025,49 @@ func (m *Model) renderChat() string {
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
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 ---",
"1. Poke (Not Impl)",
"2. Toggle Local Mute",
"+/-: Adjust Volume",
"",
"(Press ESC to close)",
}
return lipgloss.JoinVertical(lipgloss.Left, info...)
}

View File

@@ -26,7 +26,15 @@ type Player struct {
// User buffers for mixing
// map[SenderID] -> AudioQueue
userBuffers map[uint16][]int16
bufferMu sync.Mutex
// User settings
userSettings map[uint16]*UserSettings
bufferMu sync.Mutex
}
type UserSettings struct {
Volume float32 // 0.0 - 1.0 (or higher for boost)
Muted bool
}
const (
@@ -105,6 +113,7 @@ func NewPlayer() (*Player, error) {
muted: false,
stopChan: make(chan struct{}),
userBuffers: make(map[uint16][]int16),
userSettings: make(map[uint16]*UserSettings),
}, nil
}
@@ -162,6 +171,11 @@ func (p *Player) PlayPCM(senderID uint16, samples []int16) {
p.bufferMu.Lock()
defer p.bufferMu.Unlock()
// Check per-user mute
if settings, ok := p.userSettings[senderID]; ok && settings.Muted {
return
}
// Append to user's specific buffer
// This ensures sequential playback for the same user
p.userBuffers[senderID] = append(p.userBuffers[senderID], samples...)
@@ -201,13 +215,45 @@ func (p *Player) SetMuted(muted bool) {
p.mu.Unlock()
}
// IsMuted returns mute state
func (p *Player) IsMuted() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.muted
}
// SetUserVolume sets volume for a specific user (1.0 is default)
func (p *Player) SetUserVolume(clientID uint16, vol float32) {
p.bufferMu.Lock()
defer p.bufferMu.Unlock()
if _, ok := p.userSettings[clientID]; !ok {
p.userSettings[clientID] = &UserSettings{Volume: 1.0, Muted: false}
}
p.userSettings[clientID].Volume = vol
}
// SetUserMuted sets mute state for a specific user
func (p *Player) SetUserMuted(clientID uint16, muted bool) {
p.bufferMu.Lock()
defer p.bufferMu.Unlock()
if _, ok := p.userSettings[clientID]; !ok {
p.userSettings[clientID] = &UserSettings{Volume: 1.0, Muted: false}
}
p.userSettings[clientID].Muted = muted
}
// GetUserSettings gets current volume and mute state for user
func (p *Player) GetUserSettings(clientID uint16) (float32, bool) {
p.bufferMu.Lock()
defer p.bufferMu.Unlock()
if settings, ok := p.userSettings[clientID]; ok {
return settings.Volume, settings.Muted
}
return 1.0, false
}
func (p *Player) playbackLoop() {
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
@@ -249,7 +295,14 @@ func (p *Player) writeFrame() {
}
for i := 0; i < toTake; i++ {
mixed[i] += int32(buf[i])
sample := int32(buf[i])
// Apply user volume if set
if settings, ok := p.userSettings[id]; ok {
sample = int32(float32(sample) * settings.Volume)
}
mixed[i] += sample
}
// Advance buffer