From 5b8e89e9a2a5f700164f4bd97f2e60d793ec054c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es=20Ojados?= Date: Sat, 17 Jan 2026 00:19:49 +0100 Subject: [PATCH] Implement per-user audio control (vol/mute) and User View UI --- cmd/tui/model.go | 394 ++++++++++++++++++++++++++++-------------- pkg/audio/playback.go | 59 ++++++- 2 files changed, 321 insertions(+), 132 deletions(-) diff --git a/cmd/tui/model.go b/cmd/tui/model.go index 996a9ab..37e5403 100644 --- a/cmd/tui/model.go +++ b/cmd/tui/model.go @@ -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...) +} diff --git a/pkg/audio/playback.go b/pkg/audio/playback.go index 9d4dc0d..5e9c47a 100644 --- a/pkg/audio/playback.go +++ b/pkg/audio/playback.go @@ -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