diff --git a/README.md b/README.md
index d2507fc..0c01d25 100644
--- a/README.md
+++ b/README.md
@@ -226,6 +226,7 @@ When in the treeview, pressing:
* left or right arrow on a channel changes the volume for all users in that channel
* left or right arrow on a user changes the volume for that user.
* backspace restores the focused user, or every user in the focused channel, to unmuted, 100% volume, and normal boost.
+* m locally mutes or unmutes the focused user or channel. On your own user, it changes your Mumble self-mute state instead.
* enter on de-selected user selects that user for PM mode.
* enter on selected user de-selects the user
* enter on a channel de-selects any selected users (if any) and moves you to the specified channel.
@@ -300,7 +301,8 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
### Key bindings
-- F1: toggle voice transmission
+- Ctrl+T: toggle voice transmission
+- Alt+M: toggle your Mumble self-mute state from anywhere
- F9: toggle noise suppression
- F12: toggle automatic gain control
- F10: open actions menu for the focused tree item
@@ -313,7 +315,7 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH)
- End: scroll chat to the bottom
- Ctrl+Q: quit
-With the user/channel tree focused, Left/Right changes incoming volume and Backspace restores the focused user or channel to unmuted, 100% volume, and normal boost. In the message input, those keys retain their normal text-editing behavior.
+With the user/channel tree focused, M locally mutes the focused user or channel, Left/Right changes incoming volume, and Backspace restores the focused user or channel to unmuted, 100% volume, and normal boost. M on your own user toggles self-mute. In the message input, printable letters and editing keys retain their normal behavior.
## License
diff --git a/barnard.go b/barnard.go
index a6c636a..448f139 100644
--- a/barnard.go
+++ b/barnard.go
@@ -3,6 +3,7 @@ package main
import (
"crypto/tls"
"sync"
+ "unicode"
"git.stormux.org/storm/barnard/config"
"git.stormux.org/storm/barnard/fileplayback"
@@ -251,6 +252,13 @@ func (b *Barnard) StopTransmission() {
}
func (b *Barnard) TreeItemCharacter(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, ch rune) {
+ if b.Hotkeys != nil && matchesCharacterHotkey(b.Hotkeys.MuteToggle, ch) {
+ b.TreeItemKeyPress(ui, tree, item, *b.Hotkeys.MuteToggle)
+ }
+}
+
+func matchesCharacterHotkey(configured *uiterm.Key, ch rune) bool {
+ return configured != nil && uiterm.Key(unicode.ToLower(ch)) == *configured
}
func matchesVolumeResetKey(configured *uiterm.Key, pressed uiterm.Key) bool {
@@ -308,6 +316,11 @@ func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm
// Update channel mute state
b.setChannelMuted(treeItem.Channel.ID, channelWillBeMuted)
+ state := "unmuted"
+ if channelWillBeMuted {
+ state = "muted"
+ }
+ b.AddOutputLine("Locally " + state + " channel " + treeItem.Channel.Name)
if channelWillBeMuted && b.Client.Self.Channel.ID == treeItem.Channel.ID && b.isTransmitting() {
b.StopTransmission()
}
@@ -329,13 +342,22 @@ func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm
if treeItem.User != nil {
if key == *b.Hotkeys.MuteToggle {
- // Toggle mute for single user
- if err := b.UserConfig.ToggleMute(treeItem.User); err != nil {
- b.AddOutputLine("Mute: could not save setting: " + err.Error())
+ if b.Client.Self != nil && treeItem.User.Session == b.Client.Self.Session {
+ b.toggleSelfMute()
+ } else {
+ // Other users are muted only in this Barnard client.
+ if err := b.UserConfig.ToggleMute(treeItem.User); err != nil {
+ b.AddOutputLine("Mute: could not save setting: " + err.Error())
+ }
+ b.updateUserGain(treeItem.User)
+ state := "unmuted"
+ if treeItem.User.LocallyMuted() {
+ state = "muted"
+ }
+ b.AddOutputLine("Locally " + state + " " + treeItem.User.Name)
+ b.RebuildUserChannelTreePreservingSelection()
+ b.Ui.Refresh()
}
- b.updateUserGain(treeItem.User)
- b.RebuildUserChannelTreePreservingSelection()
- b.Ui.Refresh()
}
if key == *b.Hotkeys.VolumeDown {
b.changeVolume([]*gumble.User{treeItem.User}, -0.1)
diff --git a/client_notification_test.go b/client_notification_test.go
index 9cbde63..4d3b909 100644
--- a/client_notification_test.go
+++ b/client_notification_test.go
@@ -132,6 +132,24 @@ func TestConcurrentConnectionStateAccess(t *testing.T) {
wg.Wait()
}
+func TestSelfMutedReadsProtocolState(t *testing.T) {
+ b := &Barnard{Client: &gumble.Client{Self: &gumble.User{SelfMuted: true}}}
+ if !b.selfMuted() {
+ t.Fatal("self mute state was not reported")
+ }
+}
+
+func TestTransmitDoesNotStartWhileSelfMuted(t *testing.T) {
+ b := &Barnard{
+ Client: &gumble.Client{Self: &gumble.User{SelfMuted: true, Channel: &gumble.Channel{ID: 1}}},
+ Connected: true,
+ }
+ b.setTransmit(nil, 2)
+ if b.isTransmitting() {
+ t.Fatal("transmission started while self-muted")
+ }
+}
+
func TestConcurrentSelectedUserAccess(t *testing.T) {
b := &Barnard{}
user := &gumble.User{Session: 1}
diff --git a/config/hotkey_config.go b/config/hotkey_config.go
index 2eaf883..b1a6317 100644
--- a/config/hotkey_config.go
+++ b/config/hotkey_config.go
@@ -22,4 +22,5 @@ type Hotkeys struct {
AdminMenu *uiterm.Key
NoiseSuppressionToggle *uiterm.Key
AGCToggle *uiterm.Key
+ SelfMuteToggle *uiterm.Key
}
diff --git a/config/user_config.go b/config/user_config.go
index 80a87f8..fe2a656 100644
--- a/config/user_config.go
+++ b/config/user_config.go
@@ -94,11 +94,11 @@ func (c *Config) LoadConfig() {
var jc exportableConfig
jc = exportableConfig{}
jc.Hotkeys = &Hotkeys{
- Talk: key(uiterm.KeyF1),
+ Talk: key(uiterm.KeyCtrlT),
VolumeDown: key(uiterm.KeyArrowLeft),
VolumeUp: key(uiterm.KeyArrowRight),
VolumeReset: key(uiterm.KeyBackspace),
- MuteToggle: key(uiterm.KeyF7), // Added mute toggle hotkey
+ MuteToggle: key(uiterm.KeyM),
RecordToggle: key(uiterm.KeyCtrlR),
Exit: key(uiterm.KeyCtrlQ),
ToggleTimestamps: key(uiterm.KeyF3),
@@ -111,6 +111,7 @@ func (c *Config) LoadConfig() {
AdminMenu: key(uiterm.KeyF10),
NoiseSuppressionToggle: key(uiterm.KeyF9),
AGCToggle: key(uiterm.KeyF12),
+ SelfMuteToggle: key(uiterm.KeyAltM),
}
if fileExists(c.fn) {
var data []byte
@@ -181,11 +182,11 @@ func (c *Config) ensureHotkeys() {
c.config.Hotkeys = &Hotkeys{}
}
defaults := Hotkeys{
- Talk: key(uiterm.KeyF1),
+ Talk: key(uiterm.KeyCtrlT),
VolumeDown: key(uiterm.KeyArrowLeft),
VolumeUp: key(uiterm.KeyArrowRight),
VolumeReset: key(uiterm.KeyBackspace),
- MuteToggle: key(uiterm.KeyF7),
+ MuteToggle: key(uiterm.KeyM),
RecordToggle: key(uiterm.KeyCtrlR),
Exit: key(uiterm.KeyCtrlQ),
ToggleTimestamps: key(uiterm.KeyF3),
@@ -198,6 +199,7 @@ func (c *Config) ensureHotkeys() {
AdminMenu: key(uiterm.KeyF10),
NoiseSuppressionToggle: key(uiterm.KeyF9),
AGCToggle: key(uiterm.KeyF12),
+ SelfMuteToggle: key(uiterm.KeyAltM),
}
hotkeys := c.config.Hotkeys
if hotkeys.Talk == nil {
@@ -251,6 +253,9 @@ func (c *Config) ensureHotkeys() {
if hotkeys.AGCToggle == nil {
hotkeys.AGCToggle = defaults.AGCToggle
}
+ if hotkeys.SelfMuteToggle == nil {
+ hotkeys.SelfMuteToggle = defaults.SelfMuteToggle
+ }
}
func (c *Config) findServer(address string) *server {
diff --git a/config/user_config_test.go b/config/user_config_test.go
index 4b6f1c3..cc97978 100644
--- a/config/user_config_test.go
+++ b/config/user_config_test.go
@@ -87,9 +87,12 @@ func TestNewHotkeyDefaults(t *testing.T) {
got *uiterm.Key
want uiterm.Key
}{
+ {name: "talk", got: hotkeys.Talk, want: uiterm.KeyCtrlT},
{name: "volume down", got: hotkeys.VolumeDown, want: uiterm.KeyArrowLeft},
{name: "volume up", got: hotkeys.VolumeUp, want: uiterm.KeyArrowRight},
{name: "volume reset", got: hotkeys.VolumeReset, want: uiterm.KeyBackspace},
+ {name: "tree mute", got: hotkeys.MuteToggle, want: uiterm.KeyM},
+ {name: "self mute", got: hotkeys.SelfMuteToggle, want: uiterm.KeyAltM},
{name: "actions menu", got: hotkeys.AdminMenu, want: uiterm.KeyF10},
{name: "exit", got: hotkeys.Exit, want: uiterm.KeyCtrlQ},
}
@@ -102,14 +105,15 @@ func TestNewHotkeyDefaults(t *testing.T) {
func TestExplicitLegacyHotkeysRemainConfigured(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "barnard.toml")
- data := []byte("[hotkeys]\nvolumedown = \"f5\"\nvolumeup = \"f6\"\nvolumereset = \"f8\"\nadminmenu = \"f11\"\nexit = \"f10\"\n")
+ data := []byte("[hotkeys]\ntalk = \"f1\"\nvolumedown = \"f5\"\nvolumeup = \"f6\"\nvolumereset = \"f8\"\nmutetoggle = \"f7\"\nadminmenu = \"f11\"\nexit = \"f10\"\n")
if err := os.WriteFile(configPath, data, 0600); err != nil {
t.Fatal(err)
}
hotkeys := NewConfig(&configPath).GetHotkeys()
- if *hotkeys.VolumeDown != uiterm.KeyF5 || *hotkeys.VolumeUp != uiterm.KeyF6 ||
- *hotkeys.VolumeReset != uiterm.KeyF8 || *hotkeys.AdminMenu != uiterm.KeyF11 ||
+ if *hotkeys.Talk != uiterm.KeyF1 || *hotkeys.VolumeDown != uiterm.KeyF5 ||
+ *hotkeys.VolumeUp != uiterm.KeyF6 || *hotkeys.VolumeReset != uiterm.KeyF8 ||
+ *hotkeys.MuteToggle != uiterm.KeyF7 || *hotkeys.AdminMenu != uiterm.KeyF11 ||
*hotkeys.Exit != uiterm.KeyF10 {
t.Fatalf("explicit legacy hotkeys were replaced: %+v", hotkeys)
}
diff --git a/extras/barnard-sound.sh b/extras/barnard-sound.sh
index 044167c..4e156c8 100755
--- a/extras/barnard-sound.sh
+++ b/extras/barnard-sound.sh
@@ -64,6 +64,14 @@ micup() {
[[ $notify ]] && notify "You are now transmitting."
}
+mute() {
+ [[ $notify ]] && notify "You muted yourself."
+}
+
+unmute() {
+ [[ $notify ]] && notify "You unmuted yourself."
+}
+
msg() {
[[ $sound ]] && play -n synth .3 sin 1290:1490 sin 1494:1294 remix - norm -8
[[ $notify ]] && notify "$1 from $2: $3"
diff --git a/ui.go b/ui.go
index e266cea..8566a58 100644
--- a/ui.go
+++ b/ui.go
@@ -205,6 +205,46 @@ func (b *Barnard) OnVoiceToggle(ui *uiterm.Ui, key uiterm.Key) {
b.setTransmit(ui, 2)
}
+func (b *Barnard) OnSelfMuteToggle(ui *uiterm.Ui, key uiterm.Key) {
+ b.toggleSelfMute()
+}
+
+func (b *Barnard) selfMuted() bool {
+ if b.Client == nil || b.Client.Self == nil {
+ return false
+ }
+ muted := false
+ b.Client.Do(func() { muted = b.Client.Self.SelfMuted })
+ return muted
+}
+
+func (b *Barnard) setSelfMute(muted bool) {
+ if !b.isConnected() || b.Client == nil || b.Client.Self == nil {
+ b.Notify("error", "me", "cannot change self mute while disconnected")
+ b.UpdateGeneralStatus("cannot change self mute while disconnected", true)
+ return
+ }
+ if b.selfMuted() == muted {
+ return
+ }
+ if muted && b.isTransmitting() {
+ b.StopTransmission()
+ }
+ b.Client.Self.SetSelfMuted(muted)
+ event := "unmute"
+ line := "You unmuted yourself"
+ if muted {
+ event = "mute"
+ line = "You muted yourself"
+ }
+ b.Notify(event, "me", "")
+ b.AddOutputLine(line)
+}
+
+func (b *Barnard) toggleSelfMute() {
+ b.setSelfMute(!b.selfMuted())
+}
+
func (b *Barnard) CommandLog(ui *uiterm.Ui, cmd string) {
b.AddOutputLine("command " + cmd)
}
@@ -381,6 +421,10 @@ func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) {
b.Notify("error", "me", "cannot transmit in muted channel")
b.setTransmitting(false)
b.UpdateGeneralStatus("cannot transmit in muted channel", true)
+ } else if b.selfMuted() {
+ b.Notify("error", "me", "cannot transmit while self-muted")
+ b.setTransmitting(false)
+ b.UpdateGeneralStatus("cannot transmit while self-muted", true)
} else {
b.setTransmitting(true)
if b.ToneTest {
@@ -640,6 +684,7 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) {
b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews)
b.Ui.AddKeyListener(b.OnAdminMenuPress, b.Hotkeys.AdminMenu)
b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk)
+ b.Ui.AddKeyListener(b.OnSelfMuteToggle, b.Hotkeys.SelfMuteToggle)
b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps)
b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle)
b.Ui.AddKeyListener(b.OnAGCToggle, b.Hotkeys.AGCToggle)
diff --git a/ui_tree.go b/ui_tree.go
index 3db51b0..8a5fa03 100644
--- a/ui_tree.go
+++ b/ui_tree.go
@@ -14,7 +14,13 @@ func (ti TreeItem) String() string {
}
if ti.User != nil {
if ti.User.LocallyMuted() {
- return "[MUTED] " + esc(ti.User.Name)
+ return "[LOCALLY MUTED] " + esc(ti.User.Name)
+ }
+ if ti.User.Muted {
+ return "[SERVER MUTED] " + esc(ti.User.Name)
+ }
+ if ti.User.SelfMuted {
+ return "[SELF MUTED] " + esc(ti.User.Name)
}
// Calculate total volume as percentage
boostPercent := float32(ti.User.Boost()-1) * 10
@@ -76,6 +82,14 @@ func (b *Barnard) resetVolume(users []*gumble.User) {
b.AddOutputLine("Volume: could not save reset: " + err.Error())
return
}
+ if b.Client.Self != nil {
+ for _, user := range users {
+ if user != nil && user.Session == b.Client.Self.Session {
+ b.setSelfMute(false)
+ break
+ }
+ }
+ }
b.withStream(func(stream *gumbleopenal.Stream) {
for _, u := range users {
stream.UpdateUserGain(u)
@@ -148,7 +162,11 @@ func (b *Barnard) TreeItemBuild(item uiterm.TreeItem) []uiterm.TreeItem {
totalVolume := user.Volume()*100 + boostPercent
display := fmt.Sprintf("%s [%.0f%%]", esc(user.Name), totalVolume)
if user.LocallyMuted() {
- display = "[MUTED] " + display
+ display = "[LOCALLY MUTED] " + display
+ } else if user.Muted {
+ display = "[SERVER MUTED] " + display
+ } else if user.SelfMuted {
+ display = "[SELF MUTED] " + display
}
ul = append(ul, userDisplay{user: user, name: user.Name, session: user.Session, display: display})
}
diff --git a/ui_tree_test.go b/ui_tree_test.go
index 6d339d3..f2aa832 100644
--- a/ui_tree_test.go
+++ b/ui_tree_test.go
@@ -20,6 +20,42 @@ func TestMatchesVolumeResetKeyAcceptsBothTerminalBackspaces(t *testing.T) {
}
}
+func TestTreeMuteDefaultMatchesLowerAndUpperM(t *testing.T) {
+ configured := uiterm.KeyM
+ for _, ch := range []rune{'m', 'M'} {
+ if !matchesCharacterHotkey(&configured, ch) {
+ t.Errorf("%q did not match tree mute binding", ch)
+ }
+ }
+}
+
+func TestTreeItemDistinguishesMuteKinds(t *testing.T) {
+ tests := []struct {
+ name string
+ user func() *gumble.User
+ want string
+ }{
+ {
+ name: "local mute",
+ user: func() *gumble.User {
+ u := &gumble.User{Name: "Username"}
+ u.SetLocallyMuted(true)
+ return u
+ },
+ want: "[LOCALLY MUTED] Username",
+ },
+ {name: "server mute", user: func() *gumble.User { return &gumble.User{Name: "Username", Muted: true} }, want: "[SERVER MUTED] Username"},
+ {name: "self mute", user: func() *gumble.User { return &gumble.User{Name: "Username", SelfMuted: true} }, want: "[SELF MUTED] Username"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := (TreeItem{User: tt.user()}).String(); got != tt.want {
+ t.Fatalf("display = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
// Regression: rebuilding the channel tree ranged protocol-owned maps without
// Client.Do while TCP handlers could add or remove users/channels.
func TestTreeItemBuildReadsMapsUnderClientSnapshot(t *testing.T) {
diff --git a/uiterm/key_toml.go b/uiterm/key_toml.go
index ca6d59e..7e340cf 100644
--- a/uiterm/key_toml.go
+++ b/uiterm/key_toml.go
@@ -1,15 +1,31 @@
package uiterm
+import (
+ "fmt"
+ "unicode"
+ "unicode/utf8"
+)
+
// MarshalText implements the encoding.TextMarshaler interface for Key
// This allows TOML to serialize Key values as strings
func (i Key) MarshalText() ([]byte, error) {
+ if r := rune(i); r >= 0x21 && r <= unicode.MaxASCII && unicode.IsPrint(r) {
+ return []byte(string(r)), nil
+ }
return []byte(i.String()), nil
}
// UnmarshalText implements the encoding.TextUnmarshaler interface for Key
// This allows TOML to deserialize strings back into Key values
func (i *Key) UnmarshalText(data []byte) error {
+ if r, size := utf8.DecodeRune(data); r != utf8.RuneError && size == len(data) && unicode.IsPrint(r) {
+ *i = Key(unicode.ToLower(r))
+ return nil
+ }
var err error
*i, err = KeyString(string(data))
+ if err != nil {
+ return fmt.Errorf("invalid key %q: %w", data, err)
+ }
return err
}
diff --git a/uiterm/key_toml_test.go b/uiterm/key_toml_test.go
new file mode 100644
index 0000000..a54e2f3
--- /dev/null
+++ b/uiterm/key_toml_test.go
@@ -0,0 +1,35 @@
+package uiterm
+
+import "testing"
+
+func TestPrintableKeyTextRoundTrip(t *testing.T) {
+ encoded, err := KeyM.MarshalText()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(encoded) != "m" {
+ t.Fatalf("encoded key = %q, want m", encoded)
+ }
+
+ var decoded Key
+ if err := decoded.UnmarshalText([]byte("M")); err != nil {
+ t.Fatal(err)
+ }
+ if decoded != KeyM {
+ t.Fatalf("decoded key = %v, want %v", decoded, KeyM)
+ }
+}
+
+func TestNamedKeyTextStillRoundTrips(t *testing.T) {
+ encoded, err := KeyCtrlT.MarshalText()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var decoded Key
+ if err := decoded.UnmarshalText(encoded); err != nil {
+ t.Fatal(err)
+ }
+ if decoded != KeyCtrlT {
+ t.Fatalf("decoded key = %v, want %v", decoded, KeyCtrlT)
+ }
+}
diff --git a/uiterm/printable_keys.go b/uiterm/printable_keys.go
new file mode 100644
index 0000000..a3b2710
--- /dev/null
+++ b/uiterm/printable_keys.go
@@ -0,0 +1,5 @@
+package uiterm
+
+// Printable keys normally arrive through a view's character handler rather
+// than the special-key handler.
+const KeyM Key = 'm'