Implement per-user audio control (vol/mute) and User View UI
This commit is contained in:
394
cmd/tui/model.go
394
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...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user