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 FocusChannels Focus = iota
FocusChat FocusChat
FocusInput 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 // ChatMessage represents a message in the chat
type ChatMessage struct { type ChatMessage struct {
Time time.Time Time time.Time
@@ -65,6 +73,7 @@ type Model struct {
focus Focus focus Focus
width, height int width, height int
channels []ChannelNode channels []ChannelNode
items []ListItem // Flattened list for navigation
selectedIdx int selectedIdx int
chatMessages []ChatMessage chatMessages []ChatMessage
logMessages []string // Debug logs shown in chat panel logMessages []string // Debug logs shown in chat panel
@@ -88,6 +97,10 @@ type Model struct {
// Program reference for sending messages from event handlers // Program reference for sending messages from event handlers
program *tea.Program program *tea.Program
showLog bool showLog bool
// User View
showUserView bool
viewUser *UserNode
} }
// addLog adds a message to the log panel // 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. // We'll stick to valid tree.
m.channels = sortedNodes 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) { func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -459,6 +486,7 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
// 3. Global Shortcuts (Only when NOT in Input) // 3. Global Shortcuts (Only when NOT in Input)
if m.focus != FocusInput && m.focus != FocusUserView {
switch msg.String() { switch msg.String() {
case "q": case "q":
// Quit (same as ctrl+c) // Quit (same as ctrl+c)
@@ -524,6 +552,7 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showLog = !m.showLog m.showLog = !m.showLog
return m, nil return m, nil
} }
}
// Focus-specific keys // Focus-specific keys
switch m.focus { switch m.focus {
@@ -533,6 +562,8 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleInputKeys(msg) return m.handleInputKeys(msg)
case FocusChat: case FocusChat:
return m.handleChatKeys(msg) return m.handleChatKeys(msg)
case FocusUserView:
return m.handleUserViewKeys(msg)
} }
return m, nil return m, nil
} }
@@ -541,6 +572,49 @@ func (m *Model) handleChatKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil 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) { func (m *Model) handleChannelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "up", "k": case "up", "k":
@@ -548,20 +622,33 @@ func (m *Model) handleChannelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.selectedIdx-- m.selectedIdx--
} }
case "down", "j": case "down", "j":
if m.selectedIdx < len(m.channels)-1 { if m.selectedIdx < len(m.items)-1 {
m.selectedIdx++ m.selectedIdx++
} }
case "enter": case "enter":
// Join selected channel // Join selected channel OR open user view
if m.selectedIdx < len(m.channels) && m.client != nil { if m.selectedIdx < len(m.items) && m.client != nil {
ch := m.channels[m.selectedIdx] 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) m.addLog("Attempting to join channel: %s (ID=%d)", ch.Name, ch.ID)
err := m.client.JoinChannel(ch.ID) err := m.client.JoinChannel(ch.ID)
if err != nil { if err != nil {
m.addLog("Error joining channel: %v", err) 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 return m, nil
} }
@@ -646,10 +733,15 @@ func (m *Model) View() string {
} }
channelPanel := channelPanelStyle.Render(channelContent) channelPanel := channelPanelStyle.Render(channelContent)
// Right panel (Chat or Log) // Right panel (Chat, Log, or User View)
var rightPanel string 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 // Log View
// Use chatPanelStyle default but add focus logic // Use chatPanelStyle default but add focus logic
logStyle := chatPanelStyle.Copy() logStyle := chatPanelStyle.Copy()
@@ -807,7 +899,7 @@ func (m *Model) renderStatusBar() string {
var spacerRegex = regexp.MustCompile(`^\[([*cZr]?spacer[\w\d]*)\](.*)`) var spacerRegex = regexp.MustCompile(`^\[([*cZr]?spacer[\w\d]*)\](.*)`)
func (m *Model) renderChannels() string { func (m *Model) renderChannels() string {
if len(m.channels) == 0 { if len(m.items) == 0 {
return "No channels..." return "No channels..."
} }
@@ -815,34 +907,37 @@ func (m *Model) renderChannels() string {
lines = append(lines, lipgloss.NewStyle().Bold(true).Render("CHANNELS")) lines = append(lines, lipgloss.NewStyle().Bold(true).Render("CHANNELS"))
lines = append(lines, "") lines = append(lines, "")
for i, ch := range m.channels { for i, item := range m.items {
// Indentation based on depth // If selected
isSelected := (i == m.selectedIdx)
if !item.IsUser {
// CHANNEL
ch := item.Channel
indent := strings.Repeat(" ", ch.Depth) indent := strings.Repeat(" ", ch.Depth)
prefix := indent + " " prefix := indent + " "
if i == m.selectedIdx { if isSelected {
prefix = indent + "► " prefix = indent + "► "
} }
style := lipgloss.NewStyle() style := lipgloss.NewStyle()
if i == m.selectedIdx { if isSelected {
style = style.Bold(true).Foreground(lipgloss.Color("212")) style = style.Bold(true).Foreground(lipgloss.Color("212"))
} }
// Check for spacer
displayName := ch.Name displayName := ch.Name
// Spacer rendering logic
matches := spacerRegex.FindStringSubmatch(ch.Name) matches := spacerRegex.FindStringSubmatch(ch.Name)
if len(matches) > 0 { if len(matches) > 0 {
tag := matches[1] // e.g. "spacer0", "cspacer", "*spacer" tag := matches[1]
content := matches[2] // e.g. "---", "Section" content := matches[2]
// Calculate effective width: width/4 - 6 (border+padding)
width := (m.width / 4) - 6 width := (m.width / 4) - 6
if width < 0 { if width < 0 {
width = 0 width = 0
} }
if strings.HasPrefix(tag, "*") { if strings.HasPrefix(tag, "*") {
// Repeat content
if len(content) > 0 { if len(content) > 0 {
count := width / len(content) count := width / len(content)
if count > 0 { if count > 0 {
@@ -852,9 +947,6 @@ func (m *Model) renderChannels() string {
displayName = strings.Repeat("-", width) displayName = strings.Repeat("-", width)
} }
} else if strings.HasPrefix(tag, "c") { } else if strings.HasPrefix(tag, "c") {
// Center content
if len(content) > len(displayName) { // If parsed content is safer?
// No, just align content
gap := (width - len(content)) / 2 gap := (width - len(content)) / 2
if gap > 0 { if gap > 0 {
displayName = strings.Repeat(" ", gap) + content displayName = strings.Repeat(" ", gap) + content
@@ -862,38 +954,36 @@ func (m *Model) renderChannels() string {
displayName = content displayName = content
} }
} else { } 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
}
}
} else {
// Standard spacer (left align)
displayName = content 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)
// Show users in channel prefix := indent + " └─ "
for _, user := range ch.Users { if isSelected {
userPrefix := " └─ " prefix = indent + " => "
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 = " └─ 🔊 "
} else if user.IsMe {
userStyle = userStyle.Foreground(lipgloss.Color("82"))
userPrefix = " └─ ► "
} }
lines = append(lines, userStyle.Render(userPrefix+user.Nickname)) userStyle := lipgloss.NewStyle().Faint(true)
if user.Talking {
userStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("46"))
prefix = indent + " 🔊 "
} else if user.IsMe {
userStyle = userStyle.Foreground(lipgloss.Color("82"))
}
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...) 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,9 +26,17 @@ type Player struct {
// User buffers for mixing // User buffers for mixing
// map[SenderID] -> AudioQueue // map[SenderID] -> AudioQueue
userBuffers map[uint16][]int16 userBuffers map[uint16][]int16
// User settings
userSettings map[uint16]*UserSettings
bufferMu sync.Mutex bufferMu sync.Mutex
} }
type UserSettings struct {
Volume float32 // 0.0 - 1.0 (or higher for boost)
Muted bool
}
const ( const (
frameSamples = 960 // 20ms at 48kHz frameSamples = 960 // 20ms at 48kHz
) )
@@ -105,6 +113,7 @@ func NewPlayer() (*Player, error) {
muted: false, muted: false,
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
userBuffers: make(map[uint16][]int16), userBuffers: make(map[uint16][]int16),
userSettings: make(map[uint16]*UserSettings),
}, nil }, nil
} }
@@ -162,6 +171,11 @@ func (p *Player) PlayPCM(senderID uint16, samples []int16) {
p.bufferMu.Lock() p.bufferMu.Lock()
defer p.bufferMu.Unlock() defer p.bufferMu.Unlock()
// Check per-user mute
if settings, ok := p.userSettings[senderID]; ok && settings.Muted {
return
}
// Append to user's specific buffer // Append to user's specific buffer
// This ensures sequential playback for the same user // This ensures sequential playback for the same user
p.userBuffers[senderID] = append(p.userBuffers[senderID], samples...) p.userBuffers[senderID] = append(p.userBuffers[senderID], samples...)
@@ -201,13 +215,45 @@ func (p *Player) SetMuted(muted bool) {
p.mu.Unlock() p.mu.Unlock()
} }
// IsMuted returns mute state
func (p *Player) IsMuted() bool { func (p *Player) IsMuted() bool {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
return p.muted 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() { func (p *Player) playbackLoop() {
ticker := time.NewTicker(20 * time.Millisecond) ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
@@ -249,7 +295,14 @@ func (p *Player) writeFrame() {
} }
for i := 0; i < toTake; i++ { 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 // Advance buffer