Add mnemonic mute controls
This commit is contained in:
@@ -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
|
||||
|
||||
- <kbd>F1</kbd>: toggle voice transmission
|
||||
- <kbd>Ctrl+T</kbd>: toggle voice transmission
|
||||
- <kbd>Alt+M</kbd>: toggle your Mumble self-mute state from anywhere
|
||||
- <kbd>F9</kbd>: toggle noise suppression
|
||||
- <kbd>F12</kbd>: toggle automatic gain control
|
||||
- <kbd>F10</kbd>: 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)
|
||||
- <kbd>End</kbd>: scroll chat to the bottom
|
||||
- <kbd>Ctrl+Q</kbd>: 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
|
||||
|
||||
|
||||
+23
-1
@@ -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,14 +342,23 @@ 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 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()
|
||||
}
|
||||
}
|
||||
if key == *b.Hotkeys.VolumeDown {
|
||||
b.changeVolume([]*gumble.User{treeItem.User}, -0.1)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -22,4 +22,5 @@ type Hotkeys struct {
|
||||
AdminMenu *uiterm.Key
|
||||
NoiseSuppressionToggle *uiterm.Key
|
||||
AGCToggle *uiterm.Key
|
||||
SelfMuteToggle *uiterm.Key
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
+20
-2
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user