parent
be53bfd21a
commit
d0f53d0722
25 changed files with 1073 additions and 97 deletions
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
|
@ -36,13 +36,19 @@ jobs:
|
|||
if: ${{ matrix.os != 'windows-11-arm' }}
|
||||
run: |
|
||||
go test -race -v ./...
|
||||
go test -race -v -tags no_spoof_tls_fingerprint ./...
|
||||
go test -race -v -tags no_spoof_tls_fingerprint,no_voice ./...
|
||||
# -race is not supported on windows/arm64.
|
||||
- name: Test
|
||||
if: ${{ matrix.os == 'windows-11-arm' }}
|
||||
run: |
|
||||
go test -v ./...
|
||||
go test -v -tags no_spoof_tls_fingerprint ./...
|
||||
go test -v -tags no_spoof_tls_fingerprint,no_voice ./...
|
||||
|
||||
# Without voice, no C compiler is needed.
|
||||
- name: Build without voice
|
||||
run: go build -tags no_voice -o ${{ runner.temp }}/discordo .
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
- name: Build
|
||||
run: go build -trimpath -ldflags=-s .
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -28,15 +28,22 @@ scoop install discordo
|
|||
|
||||
### Building from source
|
||||
|
||||
```bash
|
||||
Requires [Go](https://go.dev/dl). Voice support requires a C compiler, and `libpulse` or `libasound` on Linux.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/ayn2op/discordo
|
||||
cd discordo
|
||||
go build .
|
||||
```
|
||||
|
||||
#### Tags
|
||||
|
||||
- `no_voice` to build without voice support.
|
||||
- `no_spoof_tls_fingerprint` to build without TLS fingerprint spoofing.
|
||||
|
||||
## Usage
|
||||
|
||||
### Password (UI, recommended)
|
||||
### Password (UI)
|
||||
|
||||
1. Run the `discordo` executable with no arguments.
|
||||
|
||||
|
|
@ -72,8 +79,20 @@ The configuration file allows you to configure and customize the behavior, keybi
|
|||
- Darwin: `$HOME/Library/Application Support/discordo/config.toml`
|
||||
- Windows: `%AppData%/discordo/config.toml`
|
||||
|
||||
Use `-config-path` to change the path.
|
||||
|
||||
Discordo uses the default configuration if a configuration file is not found in the aforementioned path; however, the default configuration file is not written to the path. [The configuration reference can be found here](./docs/config.md).
|
||||
|
||||
## Logs
|
||||
|
||||
The log file is replaced on each run.
|
||||
|
||||
- Unix: `$XDG_CACHE_HOME/discordo/logs.txt` or `$HOME/.cache/discordo/logs.txt`
|
||||
- Darwin: `$HOME/Library/Caches/discordo/logs.txt`
|
||||
- Windows: `%LocalAppData%/discordo/logs.txt`
|
||||
|
||||
Use `-log-path` to change the path, and `-log-level` (`debug`, `info`, `warn`, or `error`) to set the verbosity of what is logged.
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2025-present ayn2op
|
||||
|
|
|
|||
|
|
@ -406,6 +406,14 @@ Maximum height. 0 makes the list as tall as needed.
|
|||
|
||||
Default: `0`
|
||||
|
||||
# voice
|
||||
|
||||
## voice.sensitivity
|
||||
|
||||
How loud the microphone must be for what is said to be sent, in dB below the loudest it can be.
|
||||
|
||||
Default: `-40.0`
|
||||
|
||||
# icons
|
||||
|
||||
## icons.guild_category
|
||||
|
|
@ -448,6 +456,16 @@ Default: `"≡ "`
|
|||
|
||||
Default: `"s-"`
|
||||
|
||||
## icons.voice_muted
|
||||
|
||||
Shown after the members of a voice channel who are muted or deafened.
|
||||
|
||||
Default: `" (muted)"`
|
||||
|
||||
## icons.voice_deafened
|
||||
|
||||
Default: `" (deafened)"`
|
||||
|
||||
# keybinds
|
||||
|
||||
Global shortcuts. A keybind is a key such as "ctrl+k", or a list of keys.
|
||||
|
|
@ -468,6 +486,22 @@ Default: `"alt+m"`
|
|||
|
||||
Default: `"ctrl+k"`
|
||||
|
||||
## keybinds.toggle_mute
|
||||
|
||||
Start or stop sending what the microphone captures to the voice channel joined.
|
||||
|
||||
Default: `"alt+v"`
|
||||
|
||||
## keybinds.toggle_deafen
|
||||
|
||||
Stop or start playing what is said in the voice channel joined. Stopping also mutes.
|
||||
|
||||
Default: `"alt+d"`
|
||||
|
||||
## keybinds.leave_voice
|
||||
|
||||
Default: `"alt+l"`
|
||||
|
||||
## keybinds.toggle_help
|
||||
|
||||
Hide/show keybind help.
|
||||
|
|
@ -616,6 +650,12 @@ Default: `"K"`
|
|||
|
||||
Default: `"J"`
|
||||
|
||||
### keybinds.guilds_tree.toggle_voice
|
||||
|
||||
Join the highlighted voice channel to listen, or leave it.
|
||||
|
||||
Default: `"v"`
|
||||
|
||||
## keybinds.members_tree
|
||||
|
||||
Only while focusing on the members tree.
|
||||
|
|
@ -948,6 +988,18 @@ Default: `{ foreground = "red" }`
|
|||
|
||||
Default: `{ foreground = "gray" }`
|
||||
|
||||
### theme.guilds_tree.speaking_style
|
||||
|
||||
The style of whoever is speaking in the voice channel joined.
|
||||
|
||||
Default: `{ foreground = "green" }`
|
||||
|
||||
### theme.guilds_tree.voice_limit_style
|
||||
|
||||
The style of how full a voice channel that has a user limit is.
|
||||
|
||||
Default: `{ foreground = "dodgerblue" }`
|
||||
|
||||
## theme.members_tree
|
||||
|
||||
The status styles are those of the dot before each member.
|
||||
|
|
|
|||
8
go.mod
8
go.mod
|
|
@ -13,12 +13,13 @@ require (
|
|||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/andybalholm/brotli v1.2.6
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e
|
||||
github.com/ayn2op/tview v0.0.0-20261011173501-8bd5978952e4
|
||||
github.com/bogdanfinn/fhttp v0.6.9
|
||||
github.com/bogdanfinn/tls-client v1.16.0
|
||||
github.com/gdamore/tcell/v3 v3.6.0
|
||||
github.com/gen2brain/malgo v0.11.26
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/klauspost/compress v1.20.1
|
||||
github.com/mattn/go-shellwords v1.0.16
|
||||
|
|
@ -27,6 +28,8 @@ require (
|
|||
github.com/sahilm/fuzzy v0.1.3
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||
github.com/thomas-vilte/dave-go v0.5.1
|
||||
github.com/tphakala/go-opus v1.1.0
|
||||
github.com/yuin/goldmark v1.8.6
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.design/x/clipboard v0.11.0
|
||||
|
|
@ -45,6 +48,7 @@ require (
|
|||
github.com/cloudflare/circl v1.6.5 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/dchest/jsmin v1.0.0 // indirect
|
||||
github.com/disgoorg/godave v0.3.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.8.4 // indirect
|
||||
github.com/ebitengine/purego v0.11.1 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
|
|
@ -55,6 +59,8 @@ require (
|
|||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7 // indirect
|
||||
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
|
||||
github.com/thomas-vilte/mls-go v1.6.0 // indirect
|
||||
github.com/tphakala/simd v1.11.0 // indirect
|
||||
github.com/twmb/murmur3 v1.2.1 // indirect
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||
golang.design/x/x11 v0.2.0 // indirect
|
||||
|
|
|
|||
28
go.sum
28
go.sum
|
|
@ -10,8 +10,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
|
|||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
|
||||
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c h1:Qkl3W5RXzUQUR/PyYMMKmUHlmAksxaXRFigaTscoxtw=
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c/go.mod h1:SHqcr+EmZKeNtVKZybHJn4HNz+ufA1XbeKNSTigBCEE=
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa h1:uBfUPNrKAwrKweO+vzZSaI2mawNhRZ3QFZuH7JKenq4=
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw=
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI=
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw=
|
||||
github.com/ayn2op/tview v0.0.0-20261011173501-8bd5978952e4 h1:ViehR1nggJ7caDHIj9bp2Z8VqS00wur0xrOo1E6YNGs=
|
||||
|
|
@ -38,10 +38,10 @@ github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQ
|
|||
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI=
|
||||
github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc=
|
||||
github.com/disgoorg/godave v0.3.0 h1:37F3ZuiMd8/EXeoCtTeE8iP2eT2nWsfEa4/ITwoKfe4=
|
||||
github.com/disgoorg/godave v0.3.0/go.mod h1:OreAC3hpabr39bMVA+jwOVDq1EUPXH5A0XUBiZaDI1Y=
|
||||
github.com/dlclark/regexp2/v2 v2.8.4 h1:yfDDXTn4oxyLDC10/ieOBNDCCdIVWS9WbT6ddVXsPKI=
|
||||
github.com/dlclark/regexp2/v2 v2.8.4/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
|
||||
|
|
@ -50,6 +50,8 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh
|
|||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v3 v3.6.0 h1:jW20qS6Tm38KVDqLHq9KWSvyjNl00FbhbD8rIAnHBws=
|
||||
github.com/gdamore/tcell/v3 v3.6.0/go.mod h1:CBZxkl2T+XxEyuBfOh4zaahqm27dngvhfBfCCZxJsgc=
|
||||
github.com/gen2brain/malgo v0.11.26 h1:k5WcPIKw1bbJAbPqrvNPt7nehPLoaPNcOFde2+eruiM=
|
||||
github.com/gen2brain/malgo v0.11.26/go.mod h1:xLVG3ROA33Bzol1quF3e4ehqcFuqh8QK4B8T6LQUs/M=
|
||||
github.com/godbus/dbus/v5 v5.2.3 h1:IQNSXULh6Nc4ZH8ETJ5Mdsj8loUFCWSBnsjmJkyjUB8=
|
||||
github.com/godbus/dbus/v5 v5.2.3/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||
|
|
@ -70,8 +72,6 @@ github.com/mattn/go-shellwords v1.0.16 h1:RRxAaRzU1YbzOSCj9NJqg2/VIbSWv0dnPoD3Ew
|
|||
github.com/mattn/go-shellwords v1.0.16/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
||||
github.com/ncruces/zenity v0.10.15 h1:UaHekkxwt7QjoZmMWtzhqSEtQT97/Cux6FDBWwfyuqk=
|
||||
github.com/ncruces/zenity v0.10.15/go.mod h1:45d81tt+vF/UjBjlWE/vvE0oD0hWuHUQld4vPbA4VxE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7 h1:m1yKMZwDSXkT5o2MKhd6ihdzb2dYb6eElNE04xjOSEY=
|
||||
|
|
@ -86,10 +86,18 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE
|
|||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
|
||||
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng=
|
||||
github.com/thomas-vilte/dave-go v0.5.1 h1:sc0roOie5Ayn+3eKOUjry9kbBPDvFQCF8t+gy/EOrW4=
|
||||
github.com/thomas-vilte/dave-go v0.5.1/go.mod h1:h0UvVsCDECUe6W6dlSHEQTbx3TT13pGmHGngG0GXg1o=
|
||||
github.com/thomas-vilte/mls-go v1.6.0 h1:HGxrRe0kRfEAXu7MtTemNzqGmC/758rfGkarhYGPOYg=
|
||||
github.com/thomas-vilte/mls-go v1.6.0/go.mod h1:Cxl407ecAW94wwYLOycpQUp0UyfBk6dNmvs1hkw2yLg=
|
||||
github.com/tphakala/go-opus v1.1.0 h1:SbXQjwI/ZYy6wPXd0fafLTurkUbAhBpaYnvXYtn8uIo=
|
||||
github.com/tphakala/go-opus v1.1.0/go.mod h1:57hQHipntY2+xK7sycF4CYVSXDLW3V+AiX77iOJlojQ=
|
||||
github.com/tphakala/simd v1.11.0 h1:3rtUU/6mO76R4rq3hQiEX+DC4yKc/twIGU62bkmIAYA=
|
||||
github.com/tphakala/simd v1.11.0/go.mod h1:eO53GkWg40mRbr2tzs0Xb1IMZQ5U8cMR+Aa1RlXUinE=
|
||||
github.com/twmb/murmur3 v1.2.1 h1:TJzZGygOc1VJ7jiBpy8m7KAAzm67zy1jvkUxxEjJp+g=
|
||||
github.com/twmb/murmur3 v1.2.1/go.mod h1:LVqs/avIy0flspVtmBd4VSbogDepFl6Wkj0i0ToccHE=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
|
|
@ -103,6 +111,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||
golang.design/x/clipboard v0.11.0 h1:kEKLOBEffOeIBNI7tyKnQzgSkKWX8XTHooVde1C8O7Q=
|
||||
|
|
@ -160,5 +170,3 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
|||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
|
|
@ -95,6 +95,11 @@ type (
|
|||
Receive bool `toml:"receive"`
|
||||
}
|
||||
|
||||
Voice struct {
|
||||
// How loud the microphone must be for what is said to be sent, in dB below the loudest it can be.
|
||||
Sensitivity float64 `toml:"sensitivity"`
|
||||
}
|
||||
|
||||
Icons struct {
|
||||
GuildCategory string `toml:"guild_category"`
|
||||
GuildText string `toml:"guild_text"`
|
||||
|
|
@ -108,6 +113,10 @@ type (
|
|||
GuildAnnouncement string `toml:"guild_announcement"`
|
||||
GuildForum string `toml:"guild_forum"`
|
||||
GuildStore string `toml:"guild_store"`
|
||||
|
||||
// Shown after the members of a voice channel who are muted or deafened.
|
||||
VoiceMuted string `toml:"voice_muted"`
|
||||
VoiceDeafened string `toml:"voice_deafened"`
|
||||
}
|
||||
|
||||
PickerConfig struct {
|
||||
|
|
@ -206,6 +215,7 @@ type (
|
|||
|
||||
// Layout and behavior of the widgets.
|
||||
UI UIConfig `toml:"ui"`
|
||||
Voice Voice `toml:"voice"`
|
||||
Icons Icons `toml:"icons"`
|
||||
|
||||
// Global shortcuts. A keybind is a key such as "ctrl+k", or a list of keys.
|
||||
|
|
@ -254,7 +264,8 @@ func Default() Config {
|
|||
MembersTree: pane,
|
||||
Composer: ComposerConfig{MaxHeight: 10},
|
||||
|
||||
UI: defaultUI(),
|
||||
UI: defaultUI(),
|
||||
Voice: Voice{Sensitivity: -40},
|
||||
Icons: Icons{
|
||||
GuildText: "#",
|
||||
GuildVoice: "♪ ",
|
||||
|
|
@ -267,6 +278,9 @@ func Default() Config {
|
|||
GuildAnnouncement: "a-",
|
||||
GuildForum: "≡ ",
|
||||
GuildStore: "s-",
|
||||
|
||||
VoiceMuted: " (muted)",
|
||||
VoiceDeafened: " (deafened)",
|
||||
},
|
||||
|
||||
Keybinds: defaultKeybinds(),
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ type GuildsTreeKeybinds struct {
|
|||
// Highlight the previous or next unread guild or channel, wrapping around.
|
||||
SelectPreviousUnread Keybind `toml:"select_previous_unread"`
|
||||
SelectNextUnread Keybind `toml:"select_next_unread"`
|
||||
// Join the highlighted voice channel to listen, or leave it.
|
||||
ToggleVoice Keybind `toml:"toggle_voice"`
|
||||
}
|
||||
|
||||
type MessagesListKeybinds struct {
|
||||
|
|
@ -161,6 +163,11 @@ type Keybinds struct {
|
|||
// Hide/show the members tree.
|
||||
ToggleMembersTree Keybind `toml:"toggle_members_tree"`
|
||||
ToggleChannelsPicker Keybind `toml:"toggle_channels_picker"`
|
||||
// Start or stop sending what the microphone captures to the voice channel joined.
|
||||
ToggleMute Keybind `toml:"toggle_mute"`
|
||||
// Stop or start playing what is said in the voice channel joined. Stopping also mutes.
|
||||
ToggleDeafen Keybind `toml:"toggle_deafen"`
|
||||
LeaveVoice Keybind `toml:"leave_voice"`
|
||||
// Hide/show keybind help.
|
||||
ToggleHelp Keybind `toml:"toggle_help"`
|
||||
// Switch between compact and full keybind help.
|
||||
|
|
@ -231,6 +238,9 @@ func defaultKeybinds() Keybinds {
|
|||
ToggleGuildsTree: key("alt+g", "toggle guilds"),
|
||||
ToggleMembersTree: key("alt+m", "toggle members"),
|
||||
ToggleChannelsPicker: key("ctrl+k", "channels picker"),
|
||||
ToggleMute: key("alt+v", "toggle mute"),
|
||||
ToggleDeafen: key("alt+d", "toggle deafen"),
|
||||
LeaveVoice: key("alt+l", "leave voice"),
|
||||
ToggleHelp: key("alt+.", "toggle help"),
|
||||
ToggleFullHelp: key("ctrl+.", "full help"),
|
||||
Suspend: key("ctrl+z", "suspend"),
|
||||
|
|
@ -252,6 +262,7 @@ func defaultKeybinds() Keybinds {
|
|||
TreeKeybinds: tree,
|
||||
SelectPreviousUnread: key("K", "prev unread"),
|
||||
SelectNextUnread: key("J", "next unread"),
|
||||
ToggleVoice: key("v", "join voice"),
|
||||
},
|
||||
MembersTree: tree,
|
||||
MessagesList: MessagesListKeybinds{
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ type (
|
|||
Footer StyleThemeConfig `toml:"footer"`
|
||||
Border StyleThemeConfig `toml:"border"`
|
||||
// The status styles are those of direct-message recipients.
|
||||
GuildsTree TreeThemeConfig `toml:"guilds_tree"`
|
||||
GuildsTree GuildsTreeThemeConfig `toml:"guilds_tree"`
|
||||
// The status styles are those of the dot before each member.
|
||||
MembersTree TreeThemeConfig `toml:"members_tree"`
|
||||
ScrollBar ScrollBarThemeConfig `toml:"scroll_bar"`
|
||||
|
|
@ -216,6 +216,14 @@ type (
|
|||
}
|
||||
)
|
||||
|
||||
type GuildsTreeThemeConfig struct {
|
||||
TreeThemeConfig
|
||||
// The style of whoever is speaking in the voice channel joined.
|
||||
SpeakingStyle StyleWrapper `toml:"speaking_style"`
|
||||
// The style of how full a voice channel that has a user limit is.
|
||||
VoiceLimitStyle StyleWrapper `toml:"voice_limit_style"`
|
||||
}
|
||||
|
||||
type TreeThemeConfig struct {
|
||||
// The color of the lines that give the tree its shape.
|
||||
GraphicsColor string `toml:"graphics_color"`
|
||||
|
|
@ -257,10 +265,14 @@ func defaultTheme() ThemeConfig {
|
|||
}
|
||||
)
|
||||
return ThemeConfig{
|
||||
Title: focus,
|
||||
Footer: focus,
|
||||
Border: focus,
|
||||
GuildsTree: tree,
|
||||
Title: focus,
|
||||
Footer: focus,
|
||||
Border: focus,
|
||||
GuildsTree: GuildsTreeThemeConfig{
|
||||
TreeThemeConfig: tree,
|
||||
SpeakingStyle: StyleWrapper{plain.Foreground(tcell.ColorGreen)},
|
||||
VoiceLimitStyle: StyleWrapper{plain.Foreground(tcell.ColorDodgerBlue)},
|
||||
},
|
||||
MembersTree: tree,
|
||||
ScrollBar: ScrollBarThemeConfig{TrackStyle: dim},
|
||||
MessagesList: MessagesListThemeConfig{
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ func (m Model) selectCurrentKeybind() keybind.Keybind {
|
|||
selectHelp := selectCurrent.Help()
|
||||
selectDesc := selectHelp.Desc
|
||||
if node := m.selectionState.CurrentNode(); node != nil {
|
||||
if len(node.Children()) > 0 {
|
||||
if m.expandable(node) {
|
||||
if node.Expanded() {
|
||||
selectDesc = "collapse"
|
||||
} else {
|
||||
|
|
@ -33,9 +33,25 @@ func (m Model) selectCurrentKeybind() keybind.Keybind {
|
|||
return selectCurrent.WithHelp(selectHelp.Key, selectDesc)
|
||||
}
|
||||
|
||||
// toggleVoiceKeybinds returns the keybind that joins or leaves the highlighted voice channel, if it is one.
|
||||
func (m Model) toggleVoiceKeybinds() []keybind.Keybind {
|
||||
channel := m.voiceChannel(m.selectionState.CurrentNode())
|
||||
if channel == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
toggleVoice := m.cfg.Keybinds.GuildsTree.ToggleVoice.Keybind
|
||||
if m.voiceSession.Status().ChannelID == channel.ID {
|
||||
toggleVoice = toggleVoice.WithHelp(toggleVoice.Help().Key, "leave voice")
|
||||
}
|
||||
|
||||
return []keybind.Keybind{toggleVoice}
|
||||
}
|
||||
|
||||
func (m Model) ShortHelp() []keybind.Keybind {
|
||||
cfg := m.cfg.Keybinds.GuildsTree
|
||||
shortHelp := append(uitree.SelectHelp(m.root, m.selectionState.CurrentNode(), cfg.TreeKeybinds), m.selectCurrentKeybind())
|
||||
shortHelp = append(shortHelp, m.toggleVoiceKeybinds()...)
|
||||
if m.canCollapseParent(m.selectionState.CurrentNode()) {
|
||||
shortHelp = append(shortHelp, cfg.CollapseParentNode.Keybind)
|
||||
}
|
||||
|
|
@ -52,7 +68,7 @@ func (m Model) FullHelp() [][]keybind.Keybind {
|
|||
{cfg.ScrollUp.Keybind, cfg.ScrollDown.Keybind, cfg.ScrollTop.Keybind, cfg.ScrollBottom.Keybind, cfg.ScrollLeft.Keybind, cfg.ScrollRight.Keybind},
|
||||
selectGroup,
|
||||
{cfg.SelectPreviousUnread.Keybind, cfg.SelectNextUnread.Keybind},
|
||||
{cfg.YankID.Keybind},
|
||||
append([]keybind.Keybind{cfg.YankID.Keybind}, m.toggleVoiceKeybinds()...),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
package guildstree
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/arikawa/v3/gateway"
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/ui"
|
||||
uitree "github.com/ayn2op/discordo/internal/ui/tree"
|
||||
"github.com/ayn2op/discordo/internal/voice"
|
||||
"github.com/ayn2op/ningen/v3"
|
||||
"github.com/ayn2op/ningen/v3/states/read"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
"github.com/ayn2op/tview/richtext"
|
||||
"github.com/ayn2op/tview/tree"
|
||||
"github.com/ayn2op/tview/viewport"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
|
|
@ -25,8 +31,9 @@ type Model struct {
|
|||
selectionState tree.SelectionState
|
||||
scrollState viewport.ScrollState
|
||||
|
||||
cfg *config.Config
|
||||
state *ningen.State
|
||||
cfg *config.Config
|
||||
state *ningen.State
|
||||
voiceSession *voice.Session
|
||||
|
||||
// nodes indexes the guild and channel nodes for frequent event handlers (read updates, picker navigation).
|
||||
// It mirrors the current rendered tree and is rebuilt on READY before nodes are added.
|
||||
|
|
@ -36,10 +43,11 @@ type Model struct {
|
|||
|
||||
func NewModel(cfg *config.Config, state *ningen.State) Model {
|
||||
return Model{
|
||||
root: tree.NewNode(""),
|
||||
cfg: cfg,
|
||||
state: state,
|
||||
nodes: make(map[discord.Snowflake]*tree.Node),
|
||||
root: tree.NewNode(""),
|
||||
cfg: cfg,
|
||||
state: state,
|
||||
voiceSession: voice.New(state.State, cfg.Voice.Sensitivity),
|
||||
nodes: make(map[discord.Snowflake]*tree.Node),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -57,13 +65,15 @@ func (m Model) View(focused bool) tview.Widget {
|
|||
m.selectionState,
|
||||
m.scrollState,
|
||||
m.cfg,
|
||||
m.cfg.Theme.GuildsTree,
|
||||
m.cfg.Theme.GuildsTree.TreeThemeConfig,
|
||||
m.cfg.UI.GuildsTree.Graphics,
|
||||
m.cfg.Keybinds.GuildsTree.TreeKeybinds,
|
||||
focused,
|
||||
onChange,
|
||||
onSelect,
|
||||
).Title("Guilds")
|
||||
).
|
||||
Title("Guilds").
|
||||
Footer(m.voiceFooter())
|
||||
}
|
||||
|
||||
func (m *Model) reset() *tree.Node {
|
||||
|
|
@ -238,11 +248,196 @@ func (m Model) createChannelNode(parent *tree.Node, channel discord.Channel) {
|
|||
m.nodes[discord.Snowflake(channel.ID)] = channelNode
|
||||
}
|
||||
|
||||
// showVoiceMembers lists who is in each voice channel of the guild under it.
|
||||
func (m *Model) showVoiceMembers(guildID discord.GuildID) {
|
||||
states, _ := m.state.Cabinet.VoiceStates(guildID)
|
||||
slices.SortFunc(states, func(a, b discord.VoiceState) int { return cmp.Compare(a.UserID, b.UserID) })
|
||||
|
||||
channels, _ := m.state.Cabinet.Channels(guildID)
|
||||
for _, channel := range channels {
|
||||
node := m.nodes[discord.Snowflake(channel.ID)]
|
||||
if node == nil || !isVoice(channel.Type) {
|
||||
continue
|
||||
}
|
||||
|
||||
// The members are replaced, so what is selected cannot stay one of them.
|
||||
if slices.Contains(node.Children(), m.selectionState.CurrentNode()) {
|
||||
m.selectionState.SetCurrentNode(node)
|
||||
}
|
||||
|
||||
node.ClearChildren()
|
||||
for _, state := range states {
|
||||
if state.ChannelID == channel.ID {
|
||||
node.AddChild(tree.NewNode(m.voiceMemberText(guildID, state)).SetReference(state.UserID))
|
||||
}
|
||||
}
|
||||
|
||||
// After its name, how full it is, if it can be.
|
||||
line := node.Line()[:1]
|
||||
if channel.VoiceUserLimit > 0 {
|
||||
label := fmt.Sprintf("%d/%d", len(node.Children()), channel.VoiceUserLimit)
|
||||
line = append(line, richtext.NewSegment(" ", tview.Style{}), richtext.NewSegment(label, m.cfg.Theme.GuildsTree.VoiceLimitStyle.Style))
|
||||
}
|
||||
|
||||
node.SetLine(line)
|
||||
}
|
||||
|
||||
m.styleVoiceMembers()
|
||||
}
|
||||
|
||||
// voiceMemberText returns the name of the member that state is of, and whether they are muted or deafened.
|
||||
func (m Model) voiceMemberText(guildID discord.GuildID, state discord.VoiceState) string {
|
||||
text := state.UserID.String()
|
||||
member := state.Member
|
||||
if member == nil {
|
||||
member, _ = m.state.Cabinet.Member(guildID, state.UserID)
|
||||
}
|
||||
|
||||
if member != nil {
|
||||
text = cmp.Or(member.Nick, member.User.DisplayName, member.User.Username)
|
||||
}
|
||||
|
||||
return text + m.voiceMarkers(state)
|
||||
}
|
||||
|
||||
// voiceMarkers returns what shows that the user that state is of is muted or deafened.
|
||||
func (m Model) voiceMarkers(state discord.VoiceState) (markers string) {
|
||||
if state.Mute || state.SelfMute {
|
||||
markers += m.cfg.Icons.VoiceMuted
|
||||
}
|
||||
|
||||
if state.Deaf || state.SelfDeaf {
|
||||
markers += m.cfg.Icons.VoiceDeafened
|
||||
}
|
||||
|
||||
return markers
|
||||
}
|
||||
|
||||
// styleVoiceMembers shows who is speaking in the voice channel joined.
|
||||
func (m Model) styleVoiceMembers() {
|
||||
node := m.nodes[discord.Snowflake(m.voiceSession.Status().ChannelID)]
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, member := range node.Children() {
|
||||
var style tview.Style
|
||||
if userID, _ := member.Reference().(discord.UserID); m.voiceSession.Speaking(userID) {
|
||||
style = m.cfg.Theme.GuildsTree.SpeakingStyle.Style
|
||||
}
|
||||
|
||||
m.setNodeLineStyle(member, style)
|
||||
}
|
||||
}
|
||||
|
||||
// voiceFooter returns the state of the voice channel joined or being joined and of the user in it, or "" if there is none.
|
||||
func (m Model) voiceFooter() string {
|
||||
status := m.voiceSession.Status()
|
||||
channel, err := m.state.Cabinet.Channel(status.ChannelID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
name := ui.ChannelToString(*channel, m.cfg.Icons, m.state)
|
||||
if me, _ := m.state.Cabinet.Me(); me != nil {
|
||||
if state, _ := m.state.Cabinet.VoiceState(channel.GuildID, me.ID); state != nil {
|
||||
name += m.voiceMarkers(*state)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case !status.Joined:
|
||||
return "Joining " + name + "..."
|
||||
case m.voiceSession.Latency() == 0:
|
||||
return "In " + name
|
||||
default:
|
||||
return "In " + name + " (" + m.voiceSession.Latency().Round(time.Millisecond).String() + ")"
|
||||
}
|
||||
}
|
||||
|
||||
// voiceChannel returns the voice channel of node, or nil if it is not one.
|
||||
func (m Model) voiceChannel(node *tree.Node) *discord.Channel {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
channelID, _ := node.Reference().(discord.ChannelID)
|
||||
channel, err := m.state.Cabinet.Channel(channelID)
|
||||
if err != nil || !isVoice(channel.Type) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return channel
|
||||
}
|
||||
|
||||
// joinable returns why the voice channel of node cannot be joined, or nil if it can be.
|
||||
func (m Model) joinable(node *tree.Node, channel discord.Channel) error {
|
||||
switch {
|
||||
case !m.state.HasPermissions(channel.ID, discord.PermissionConnect):
|
||||
return errors.New("you are not permitted to connect")
|
||||
// Discord does not answer a request to join a full voice channel.
|
||||
case channel.VoiceUserLimit > 0 && len(node.Children()) >= int(channel.VoiceUserLimit) && !m.state.HasPermissions(channel.ID, discord.PermissionMoveMembers):
|
||||
return errors.New("it is full")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// toggleVoice joins the voice channel of node, or leaves it if it is joined.
|
||||
func (m Model) toggleVoice(node *tree.Node) tview.Cmd {
|
||||
channel := m.voiceChannel(node)
|
||||
switch {
|
||||
case channel == nil:
|
||||
return nil
|
||||
case m.voiceSession.Status().ChannelID == channel.ID:
|
||||
return voiceCmd("leave "+channel.Name, m.voiceSession.Leave)
|
||||
}
|
||||
|
||||
err := m.joinable(node, *channel)
|
||||
return voiceCmd("join "+channel.Name, func() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.voiceSession.Join(channel.ID)
|
||||
})
|
||||
}
|
||||
|
||||
// toggleMute starts or stops sending what the user says to the voice channel joined, if they are permitted to speak in it without holding a key.
|
||||
func (m Model) toggleMute() tview.Cmd {
|
||||
return voiceCmd("toggle mute", func() error {
|
||||
if channelID := m.voiceSession.Status().ChannelID; channelID.IsValid() && !m.state.HasPermissions(channelID, discord.PermissionSpeak|discord.PermissionUseVAD) {
|
||||
return errors.New("you are not permitted to speak by voice activity")
|
||||
}
|
||||
|
||||
return m.voiceSession.Toggle(false)
|
||||
})
|
||||
}
|
||||
|
||||
// voiceCmd returns a command that does f, and shows why it failed to do what if it did.
|
||||
func voiceCmd(what string, f func() error) tview.Cmd {
|
||||
return func() tview.Msg {
|
||||
if err := f(); err != nil {
|
||||
return ui.ErrorDialog(what, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// expandable reports whether selecting node expands or collapses it, which the members of a voice channel do not make it.
|
||||
func (m Model) expandable(node *tree.Node) bool {
|
||||
return len(node.Children()) > 0 && m.voiceChannel(node) == nil
|
||||
}
|
||||
|
||||
func isVoice(t discord.ChannelType) bool {
|
||||
return t == discord.GuildVoice || t == discord.GuildStageVoice
|
||||
}
|
||||
|
||||
// setNodeLineStyle styles the name of node, which is the first segment of its line.
|
||||
func (m Model) setNodeLineStyle(node *tree.Node, style tview.Style) {
|
||||
line := node.Line()
|
||||
for i := range line {
|
||||
line[i].Style = style
|
||||
}
|
||||
line[0].Style = style
|
||||
node.SetLine(line)
|
||||
}
|
||||
|
||||
|
|
@ -327,6 +522,12 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
case *read.UpdateEvent:
|
||||
m.refreshReadStyles(msg)
|
||||
return m, nil
|
||||
case *gateway.VoiceStateUpdateEvent:
|
||||
m.showVoiceMembers(msg.GuildID)
|
||||
return m, nil
|
||||
case VoiceMsg:
|
||||
m.styleVoiceMembers()
|
||||
return m, m.Init()
|
||||
|
||||
case NavigateMsg:
|
||||
return m, m.navigate(msg.ChannelID)
|
||||
|
|
@ -369,6 +570,14 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
return m, nil
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.YankID.Keybind):
|
||||
return m, uitree.YankID(m.selectionState.CurrentNode())
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.ToggleVoice.Keybind):
|
||||
return m, m.toggleVoice(m.selectionState.CurrentNode())
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.ToggleMute.Keybind):
|
||||
return m, m.toggleMute()
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.ToggleDeafen.Keybind):
|
||||
return m, voiceCmd("toggle deafen", func() error { return m.voiceSession.Toggle(true) })
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.LeaveVoice.Keybind):
|
||||
return m, voiceCmd("leave voice", m.voiceSession.Leave)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
|
|
|||
|
|
@ -25,6 +25,17 @@ type ChannelLoadedMsg struct {
|
|||
|
||||
type NavigateMsg struct{ ChannelID discord.ChannelID }
|
||||
|
||||
// VoiceMsg is sent when who is speaking or the state of the voice channel joined changes.
|
||||
type VoiceMsg struct{}
|
||||
|
||||
// Init waits for that.
|
||||
func (m Model) Init() tview.Cmd {
|
||||
return func() tview.Msg {
|
||||
<-m.voiceSession.Updates()
|
||||
return VoiceMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) navigate(channelID discord.ChannelID) tview.Cmd {
|
||||
channel, err := m.state.Cabinet.Channel(channelID)
|
||||
if err != nil {
|
||||
|
|
@ -45,7 +56,7 @@ func (m *Model) navigate(channelID discord.ChannelID) tview.Cmd {
|
|||
}
|
||||
|
||||
func (m Model) selectNode(node *tree.Node) tview.Cmd {
|
||||
if len(node.Children()) != 0 {
|
||||
if m.expandable(node) {
|
||||
node.SetExpanded(!node.Expanded())
|
||||
return nil
|
||||
}
|
||||
|
|
@ -60,6 +71,7 @@ func (m Model) selectNode(node *tree.Node) tview.Cmd {
|
|||
}
|
||||
ui.SortGuildChannels(channels)
|
||||
m.createChannelNodes(node, channels)
|
||||
m.showVoiceMembers(ref)
|
||||
node.Expand()
|
||||
case discord.ChannelID:
|
||||
channel, err := m.state.Cabinet.Channel(ref)
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ func (m Model) baseFullHelp() [][]keybind.Keybind {
|
|||
m.focusHelp(),
|
||||
{cfg.FocusPrevious.Keybind, cfg.FocusNext.Keybind},
|
||||
{cfg.ToggleGuildsTree.Keybind, cfg.ToggleMembersTree.Keybind, cfg.ToggleChannelsPicker.Keybind},
|
||||
{cfg.ToggleMute.Keybind, cfg.ToggleDeafen.Keybind, cfg.LeaveVoice.Keybind},
|
||||
{cfg.Logout.Keybind},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1034,11 +1034,6 @@ func messageURLs(msg discord.Message) []string {
|
|||
return urls
|
||||
}
|
||||
|
||||
func attachmentErr(what string, err error) tview.Msg {
|
||||
slog.Error("failed to "+what, "err", err)
|
||||
return ui.ModalMsg{Text: "Failed to " + what + ": " + err.Error(), Buttons: []ui.ModalButton{{Label: "OK"}}}
|
||||
}
|
||||
|
||||
func downloadAttachment(attachment discord.Attachment, destination string) error {
|
||||
resp, err := http.Get(attachment.URL)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -201,10 +201,10 @@ func (m Model) openAttachment(attachment discord.Attachment) tview.Cmd {
|
|||
|
||||
func (m Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd {
|
||||
if !m.cfg.Attachments.AllowedMIMETypes.Has(attachment.ContentType) {
|
||||
return ui.ShowModal(
|
||||
return ui.ShowDialog(
|
||||
"This attachment type is not allowed and may be unsafe. Continue anyway?",
|
||||
ui.ModalButton{Label: "No"},
|
||||
ui.ModalButton{Label: "Yes", Cmd: action},
|
||||
ui.DialogButton{Label: "No"},
|
||||
ui.DialogButton{Label: "Yes", Cmd: action},
|
||||
)
|
||||
}
|
||||
return action
|
||||
|
|
@ -222,12 +222,12 @@ func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd {
|
|||
|
||||
dir := filepath.Join(consts.CacheDir(), "attachments")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return attachmentErr("create attachments directory", err)
|
||||
return ui.ErrorDialog("create attachments directory", err)
|
||||
}
|
||||
|
||||
file, err := os.CreateTemp(dir, "attachment-*"+extension)
|
||||
if err != nil {
|
||||
return attachmentErr("create attachment file", err)
|
||||
return ui.ErrorDialog("create attachment file", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
|
|
@ -235,9 +235,9 @@ func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd {
|
|||
|
||||
if err := downloadAttachment(attachment, path); err != nil {
|
||||
os.Remove(path)
|
||||
return attachmentErr("download attachment", err)
|
||||
return ui.ErrorDialog("download attachment", err)
|
||||
} else if err := open.Start(path); err != nil {
|
||||
return attachmentErr("open attachment file", err)
|
||||
return ui.ErrorDialog("open attachment file", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -251,11 +251,11 @@ func saveAttachment(attachment discord.Attachment) tview.Cmd {
|
|||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return attachmentErr("select attachment destination", err)
|
||||
return ui.ErrorDialog("select attachment destination", err)
|
||||
}
|
||||
|
||||
if err := downloadAttachment(attachment, destination); err != nil {
|
||||
return attachmentErr("download attachment", err)
|
||||
return ui.ErrorDialog("download attachment", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -264,7 +264,7 @@ func saveAttachment(attachment discord.Attachment) tview.Cmd {
|
|||
func openURL(url string) tview.Cmd {
|
||||
return func() tview.Msg {
|
||||
if err := open.Start(url); err != nil {
|
||||
return attachmentErr("open URL", err)
|
||||
return ui.ErrorDialog("open URL", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -306,10 +306,10 @@ func (m Model) confirmDelete() tview.Cmd {
|
|||
return nil
|
||||
}
|
||||
message := *selectedMessage
|
||||
return ui.ShowModal(
|
||||
return ui.ShowDialog(
|
||||
"Are you sure you want to delete this message?",
|
||||
ui.ModalButton{Label: "Yes", Cmd: m.requestDelete(message)},
|
||||
ui.ModalButton{Label: "No"},
|
||||
ui.DialogButton{Label: "Yes", Cmd: m.requestDelete(message)},
|
||||
ui.DialogButton{Label: "No"},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ func (m *Model) cycleFocus(step pane) {
|
|||
}
|
||||
|
||||
func (m Model) Init() tview.Cmd {
|
||||
return tview.Batch(openState(m.state), listen(m.events), tview.RequestTerminalInfo())
|
||||
return tview.Batch(openState(m.state), listen(m.events), m.guildsTree.Init(), tview.RequestTerminalInfo())
|
||||
}
|
||||
|
||||
// Update returns m changed in response to msg and a command to run, or nil.
|
||||
|
|
@ -198,6 +198,8 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
switch msg := msg.(type) {
|
||||
case gateway.Event:
|
||||
return m, tview.Batch(m.applyEvent(msg), listen(m.events))
|
||||
case guildstree.VoiceMsg:
|
||||
return m, m.updatePane(paneGuildsTree, msg)
|
||||
case tview.FocusMsg:
|
||||
m.windowUnfocused = !msg.Focused
|
||||
return m, nil
|
||||
|
|
@ -290,6 +292,9 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
case keybind.Matches(msg, m.cfg.Keybinds.ToggleChannelsPicker.Keybind):
|
||||
m.togglePicker()
|
||||
return m, nil
|
||||
// The guilds tree is what the voice channel joined is in.
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.ToggleMute.Keybind, m.cfg.Keybinds.ToggleDeafen.Keybind, m.cfg.Keybinds.LeaveVoice.Keybind):
|
||||
return m, m.updatePane(paneGuildsTree, msg)
|
||||
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.Logout.Keybind):
|
||||
return m, tview.Sequence(closeState(m.state), logout())
|
||||
|
|
|
|||
|
|
@ -138,6 +138,8 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd {
|
|||
|
||||
case *read.UpdateEvent:
|
||||
return m.updatePane(paneGuildsTree, event)
|
||||
case *gateway.VoiceStateUpdateEvent:
|
||||
return m.updatePane(paneGuildsTree, event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
28
internal/ui/dialog.go
Normal file
28
internal/ui/dialog.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/ayn2op/tview"
|
||||
)
|
||||
|
||||
type DialogButton struct {
|
||||
Label string
|
||||
Cmd tview.Cmd
|
||||
KeepOpen bool
|
||||
}
|
||||
|
||||
type DialogMsg struct {
|
||||
Text string
|
||||
Buttons []DialogButton
|
||||
}
|
||||
|
||||
func ShowDialog(text string, buttons ...DialogButton) tview.Cmd {
|
||||
return func() tview.Msg { return DialogMsg{Text: text, Buttons: buttons} }
|
||||
}
|
||||
|
||||
// ErrorDialog logs that what failed with err and returns the dialog that says so.
|
||||
func ErrorDialog(what string, err error) DialogMsg {
|
||||
slog.Error("failed to "+what, "err", err)
|
||||
return DialogMsg{Text: "Failed to " + what + ": " + err.Error(), Buttons: []DialogButton{{Label: "OK"}}}
|
||||
}
|
||||
|
|
@ -24,8 +24,8 @@ type selectTabMsg int
|
|||
func showErrorDialog(err error) tview.Cmd {
|
||||
slog.Error("failed to login", "err", err)
|
||||
message := err.Error()
|
||||
return ui.ShowModal(message,
|
||||
ui.ModalButton{Label: "Copy", Cmd: setClipboard(message), KeepOpen: true},
|
||||
ui.ModalButton{Label: "Close"},
|
||||
return ui.ShowDialog(message,
|
||||
ui.DialogButton{Label: "Copy", Cmd: setClipboard(message), KeepOpen: true},
|
||||
ui.DialogButton{Label: "Close"},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
package ui
|
||||
|
||||
import "github.com/ayn2op/tview"
|
||||
|
||||
type ModalButton struct {
|
||||
Label string
|
||||
Cmd tview.Cmd
|
||||
KeepOpen bool
|
||||
}
|
||||
|
||||
type ModalMsg struct {
|
||||
Text string
|
||||
Buttons []ModalButton
|
||||
}
|
||||
|
||||
func ShowModal(text string, buttons ...ModalButton) tview.Cmd {
|
||||
return func() tview.Msg { return ModalMsg{Text: text, Buttons: buttons} }
|
||||
}
|
||||
|
|
@ -42,8 +42,8 @@ type Model struct {
|
|||
chat chat.Model
|
||||
// helpVisible reports whether help is shown below the inner model, and helpShowAll whether it shows every keybind.
|
||||
helpVisible, helpShowAll bool
|
||||
// modal is the request shown in a dialog, or nil when none is open, and dialogFocus its focused button.
|
||||
modal *ui.ModalMsg
|
||||
// dialog is the dialog shown, or nil when none is open, and dialogFocus its focused button.
|
||||
dialog *ui.DialogMsg
|
||||
dialogFocus int
|
||||
// resize is the last size of the window, or nil before the first, kept for the models shown later.
|
||||
resize tview.ResizeMsg
|
||||
|
|
@ -91,20 +91,20 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
m.showLogin(),
|
||||
deleteToken(),
|
||||
)
|
||||
case ui.ModalMsg:
|
||||
if m.modal == nil {
|
||||
m.modal, m.dialogFocus = &msg, 0
|
||||
case ui.DialogMsg:
|
||||
if m.dialog == nil {
|
||||
m.dialog, m.dialogFocus = &msg, 0
|
||||
}
|
||||
return m, nil
|
||||
case dialogFocusMsg:
|
||||
m.dialogFocus = int(msg)
|
||||
return m, nil
|
||||
case dialogDoneMsg:
|
||||
return m, m.finishModal(int(msg))
|
||||
return m, m.finishDialog(int(msg))
|
||||
|
||||
case tview.KeyMsg:
|
||||
// The dialog takes all input while it is open, so nothing behind it reacts.
|
||||
if m.modal != nil {
|
||||
if m.dialog != nil {
|
||||
return m, nil
|
||||
}
|
||||
switch {
|
||||
|
|
@ -119,10 +119,10 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
|||
case keybind.Matches(msg, m.cfg.Keybinds.Suspend.Keybind):
|
||||
return m, suspend()
|
||||
case keybind.Matches(msg, m.cfg.Keybinds.Quit.Keybind):
|
||||
return m, tview.Batch(m.updateScreen(chat.QuitMsg{}), tview.Quit())
|
||||
return m, tview.Sequence(m.updateScreen(chat.QuitMsg{}), tview.Quit())
|
||||
}
|
||||
case tview.MouseMsg, tview.PasteMsg:
|
||||
if m.modal != nil {
|
||||
if m.dialog != nil {
|
||||
return m, nil
|
||||
}
|
||||
case tview.ResizeMsg:
|
||||
|
|
@ -149,7 +149,7 @@ func (m Model) helpHeight() int {
|
|||
return m.helpView().Layout(layout.Limits{Infinite: layout.Axes{Height: true}}).Height
|
||||
}
|
||||
|
||||
// View shows the inner model above help, with the modal dialog on top of both when one is open.
|
||||
// View shows the inner model above help, with the dialog on top of both when one is open.
|
||||
func (m Model) View() tview.Widget {
|
||||
var innerView, helpView tview.Widget
|
||||
switch m.screen {
|
||||
|
|
@ -163,7 +163,7 @@ func (m Model) View() tview.Widget {
|
|||
helpView = column.New(padded).Height(layout.Fixed(max(m.helpHeight(), 1)))
|
||||
}
|
||||
content := column.New(innerView, helpView)
|
||||
if m.modal == nil {
|
||||
if m.dialog == nil {
|
||||
return content
|
||||
}
|
||||
return stack.New(inert.New(content), backdrop.New().Style(m.cfg.Theme.Dialog.BackgroundStyle.Style), m.dialogView())
|
||||
|
|
@ -187,15 +187,15 @@ type (
|
|||
dialogDoneMsg int
|
||||
)
|
||||
|
||||
// dialogView returns the dialog for the open modal request.
|
||||
// dialogView returns the view of the open dialog.
|
||||
func (m Model) dialogView() tview.Widget {
|
||||
labels := make([]string, len(m.modal.Buttons))
|
||||
for i, button := range m.modal.Buttons {
|
||||
labels := make([]string, len(m.dialog.Buttons))
|
||||
for i, button := range m.dialog.Buttons {
|
||||
labels[i] = button.Label
|
||||
}
|
||||
style := m.cfg.Theme.Dialog.Style.Style
|
||||
d := dialog.New().
|
||||
Text(m.modal.Text).
|
||||
Text(m.dialog.Text).
|
||||
Buttons(labels...).
|
||||
Focus(m.dialogFocus).
|
||||
ButtonStyle(style).
|
||||
|
|
|
|||
|
|
@ -78,20 +78,20 @@ func (m *Model) showChat(token string) tview.Cmd {
|
|||
return m.show(init)
|
||||
}
|
||||
|
||||
// show closes any open modal after the shown model changed and returns what it needs done, with init its first command.
|
||||
// show closes any open dialog after the shown model changed and returns what it needs done, with init its first command.
|
||||
func (m *Model) show(init tview.Cmd) tview.Cmd {
|
||||
m.modal = nil
|
||||
m.dialog = nil
|
||||
return tview.Batch(tview.SetTitle(consts.Name), init)
|
||||
}
|
||||
|
||||
func (m *Model) finishModal(index int) tview.Cmd {
|
||||
if m.modal == nil || index < 0 || index >= len(m.modal.Buttons) {
|
||||
m.modal = nil
|
||||
func (m *Model) finishDialog(index int) tview.Cmd {
|
||||
if m.dialog == nil || index < 0 || index >= len(m.dialog.Buttons) {
|
||||
m.dialog = nil
|
||||
return nil
|
||||
}
|
||||
button := m.modal.Buttons[index]
|
||||
button := m.dialog.Buttons[index]
|
||||
if !button.KeepOpen {
|
||||
m.modal = nil
|
||||
m.dialog = nil
|
||||
}
|
||||
return button.Cmd
|
||||
}
|
||||
|
|
|
|||
440
internal/voice/session.go
Normal file
440
internal/voice/session.go
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
//go:build !no_voice
|
||||
|
||||
package voice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/arikawa/v3/gateway"
|
||||
"github.com/ayn2op/arikawa/v3/state"
|
||||
"github.com/ayn2op/arikawa/v3/utils/ws"
|
||||
"github.com/ayn2op/arikawa/v3/voice"
|
||||
"github.com/ayn2op/arikawa/v3/voice/udp"
|
||||
"github.com/ayn2op/arikawa/v3/voice/voicegateway"
|
||||
"github.com/gen2brain/malgo"
|
||||
dave "github.com/thomas-vilte/dave-go/session"
|
||||
"github.com/tphakala/go-opus/opus"
|
||||
)
|
||||
|
||||
const (
|
||||
timeout = 15 * time.Second
|
||||
// hangover is how many frames are still sent after the last that was loud enough, for the ends of words not to be cut off.
|
||||
hangover = 15
|
||||
)
|
||||
|
||||
// audio is what the audio devices are opened with. It is of the first backend that works but the null one, which malgo would otherwise silently use when the system has no audio library.
|
||||
var audio = sync.OnceValues(func() (*malgo.AllocatedContext, error) {
|
||||
return malgo.InitContext([]malgo.Backend{
|
||||
malgo.BackendWasapi, malgo.BackendDsound, malgo.BackendWinmm,
|
||||
malgo.BackendCoreaudio,
|
||||
malgo.BackendSndio, malgo.BackendAudio4, malgo.BackendOss,
|
||||
malgo.BackendPulseaudio, malgo.BackendAlsa, malgo.BackendJack,
|
||||
}, malgo.ContextConfig{}, nil)
|
||||
})
|
||||
|
||||
// startDevice starts a playback or capture device, which calls data with what to fill or with what was captured.
|
||||
func startDevice(kind malgo.DeviceType, data func(output, input []byte)) (*malgo.Device, error) {
|
||||
ctx, err := audio()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := malgo.DefaultDeviceConfig(kind)
|
||||
config.SampleRate = sampleRate
|
||||
config.PeriodSizeInMilliseconds = 10
|
||||
config.Playback.Format, config.Playback.Channels = malgo.FormatS16, channels
|
||||
config.Capture.Format, config.Capture.Channels = malgo.FormatS16, channels
|
||||
|
||||
device, err := malgo.InitDevice(ctx.Context, config, malgo.DeviceCallbacks{
|
||||
Data: func(output, input []byte, _ uint32) { data(output, input) },
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := device.Start(); err != nil {
|
||||
device.Uninit()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
// Session is the voice channel that the user is in, if any.
|
||||
type Session struct {
|
||||
state *state.State
|
||||
// threshold is how loud a sample of the microphone must be for what is said to be sent.
|
||||
threshold int
|
||||
// updates is sent to when the status or who is speaking changes.
|
||||
updates chan struct{}
|
||||
// mic is sent what the microphone captures.
|
||||
mic chan []byte
|
||||
|
||||
// mu is held while joining, leaving and muting, which are not done while another is.
|
||||
mu sync.Mutex
|
||||
// session is nil until the first voice channel is joined.
|
||||
session atomic.Pointer[voice.Session]
|
||||
// playback plays while a voice channel is joined, and capture captures while the user is not muted in it.
|
||||
playback, capture *malgo.Device
|
||||
|
||||
stateMu sync.Mutex
|
||||
status Status
|
||||
speaking map[discord.UserID]bool
|
||||
speakers map[uint32]*speaker
|
||||
}
|
||||
|
||||
// New returns a Session that sends what the microphone captures louder than sensitivity, in dB.
|
||||
func New(state *state.State, sensitivity float64) *Session {
|
||||
s := &Session{
|
||||
state: state,
|
||||
threshold: int(math.Pow(10, sensitivity/20) * math.MaxInt16),
|
||||
updates: make(chan struct{}, 1),
|
||||
mic: make(chan []byte, 4),
|
||||
speaking: make(map[discord.UserID]bool),
|
||||
speakers: make(map[uint32]*speaker),
|
||||
}
|
||||
state.AddHandler(s.follow)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// follow keeps up with the user being moved to another voice channel or disconnected, by someone else or by connecting from another client.
|
||||
func (s *Session) follow(event *gateway.VoiceStateUpdateEvent) {
|
||||
me, _ := s.state.Cabinet.Me()
|
||||
if me == nil || event.UserID != me.ID {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
status, own := s.Status(), event.SessionID == s.state.Ready().SessionID
|
||||
switch {
|
||||
case !status.Joined || own && event.ChannelID == status.ChannelID:
|
||||
case own && event.ChannelID.IsValid():
|
||||
s.setStatus(Status{ChannelID: event.ChannelID, Joined: true})
|
||||
default:
|
||||
s.left()
|
||||
}
|
||||
}
|
||||
|
||||
// Updates is sent to when the status or who is speaking changes.
|
||||
func (s *Session) Updates() <-chan struct{} {
|
||||
return s.updates
|
||||
}
|
||||
|
||||
func (s *Session) Status() Status {
|
||||
s.stateMu.Lock()
|
||||
defer s.stateMu.Unlock()
|
||||
|
||||
return s.status
|
||||
}
|
||||
|
||||
// Speaking reports whether the user is speaking in the voice channel joined.
|
||||
func (s *Session) Speaking(userID discord.UserID) bool {
|
||||
s.stateMu.Lock()
|
||||
defer s.stateMu.Unlock()
|
||||
|
||||
return s.speaking[userID]
|
||||
}
|
||||
|
||||
// Latency returns how long the voice server of the voice channel joined takes to answer, or 0 if it is not known.
|
||||
func (s *Session) Latency() time.Duration {
|
||||
if session := s.session.Load(); session != nil {
|
||||
return session.Latency()
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// update changes the state with f and tells Updates if f reports that it did.
|
||||
func (s *Session) update(f func() bool) {
|
||||
s.stateMu.Lock()
|
||||
changed := f()
|
||||
s.stateMu.Unlock()
|
||||
|
||||
if changed {
|
||||
select {
|
||||
case s.updates <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setStatus changes the status, which whoever spoke and was heard does not outlast.
|
||||
func (s *Session) setStatus(status Status) {
|
||||
s.update(func() bool {
|
||||
s.status = status
|
||||
clear(s.speaking)
|
||||
clear(s.speakers)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) setSpeaking(userID discord.UserID, speaking bool) {
|
||||
s.update(func() bool {
|
||||
changed := s.speaking[userID] != speaking
|
||||
s.speaking[userID] = speaking
|
||||
return changed
|
||||
})
|
||||
}
|
||||
|
||||
// setDevice starts device, which then calls data, if on, and stops it otherwise.
|
||||
func setDevice(device **malgo.Device, on bool, kind malgo.DeviceType, data func(output, input []byte)) (err error) {
|
||||
switch {
|
||||
case on && *device == nil:
|
||||
*device, err = startDevice(kind, data)
|
||||
case !on && *device != nil:
|
||||
(*device).Uninit()
|
||||
*device = nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// setDevices captures what the user says unless mute, and plays what is said unless deafen.
|
||||
func (s *Session) setDevices(mute, deafen bool) error {
|
||||
err := errors.Join(
|
||||
setDevice(&s.capture, !mute, malgo.Capture, func(_, input []byte) { s.queue(bytes.Clone(input)) }),
|
||||
setDevice(&s.playback, !deafen, malgo.Playback, s.mix),
|
||||
)
|
||||
if mute {
|
||||
// Silence makes what is being said end.
|
||||
s.queue(make([]byte, 2*hangover*frameBytes))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// left stops the devices, in no voice channel.
|
||||
func (s *Session) left() {
|
||||
s.setDevices(true, true)
|
||||
s.setStatus(Status{})
|
||||
}
|
||||
|
||||
// Join joins the voice channel, muted, leaving the one that the user is in.
|
||||
func (s *Session) Join(channelID discord.ChannelID) (err error) {
|
||||
if !s.mu.TryLock() {
|
||||
return nil
|
||||
}
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.setStatus(Status{ChannelID: channelID})
|
||||
|
||||
defer func() {
|
||||
// Discord may have the user in the voice channel regardless, and joining it again would then do nothing.
|
||||
if err != nil {
|
||||
s.leave()
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
session := s.session.Load()
|
||||
if session == nil {
|
||||
if session, err = voice.NewSession(s.state, s.state.Handler); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.DAVE = dave.CreateFunc()
|
||||
session.AddHandler(func(event *ws.BackgroundErrorEvent) { slog.Debug("voice gateway error", "err", event.Err) })
|
||||
s.session.Store(session)
|
||||
|
||||
go s.receive(session)
|
||||
go s.send(session)
|
||||
}
|
||||
|
||||
if err := s.setDevices(true, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := session.JoinChannel(ctx, channelID, true, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The voice server sends nothing before it is told this.
|
||||
if err := session.Speaking(ctx, voicegateway.Microphone); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.setStatus(Status{ChannelID: channelID, Joined: true})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Leave leaves the voice channel that the user is in, if any.
|
||||
func (s *Session) Leave() error {
|
||||
if !s.mu.TryLock() {
|
||||
return nil
|
||||
}
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.leave()
|
||||
}
|
||||
|
||||
func (s *Session) leave() error {
|
||||
defer s.left()
|
||||
|
||||
session := s.session.Load()
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
return session.Leave(ctx)
|
||||
}
|
||||
|
||||
// Toggle mutes or unmutes the user in the voice channel joined, if any, or deafens or undeafens them if deafen. Deafening also mutes, and unmuting also undeafens.
|
||||
func (s *Session) Toggle(deafen bool) error {
|
||||
if !s.mu.TryLock() {
|
||||
return nil
|
||||
}
|
||||
defer s.mu.Unlock()
|
||||
|
||||
status := s.Status()
|
||||
channel, err := s.state.Cabinet.Channel(status.ChannelID)
|
||||
if err != nil || !status.Joined {
|
||||
return nil
|
||||
}
|
||||
|
||||
mute, deafen := deafen || s.capture != nil, deafen && s.playback != nil
|
||||
if err := s.setDevices(mute, deafen); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
return s.state.SendGateway(ctx, &gateway.UpdateVoiceStateCommand{
|
||||
GuildID: channel.GuildID,
|
||||
ChannelID: channel.ID,
|
||||
SelfMute: mute,
|
||||
SelfDeaf: deafen,
|
||||
})
|
||||
}
|
||||
|
||||
// queue queues what was captured to be sent, in place of the oldest if what is queued is not being sent fast enough, for what is said not to be sent ever later.
|
||||
func (s *Session) queue(pcm []byte) {
|
||||
for {
|
||||
select {
|
||||
case s.mic <- pcm:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case <-s.mic:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mix fills output with what everyone says. It is what the playback device calls.
|
||||
func (s *Session) mix(output, _ []byte) {
|
||||
s.stateMu.Lock()
|
||||
defer s.stateMu.Unlock()
|
||||
|
||||
for _, speaker := range s.speakers {
|
||||
speaker.mix(output)
|
||||
}
|
||||
}
|
||||
|
||||
// receive buffers what each user says in the voice channels that session joins.
|
||||
func (s *Session) receive(session *voice.Session) {
|
||||
for {
|
||||
packet, err := session.ReadPacket()
|
||||
if errors.Is(err, udp.ErrDecryptionFailed) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// In no voice channel, or joining one.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
s.stateMu.Lock()
|
||||
speaker := s.speakers[packet.SSRC()]
|
||||
if speaker == nil {
|
||||
if speaker, err = newSpeaker(packet.Sequence()); err != nil {
|
||||
s.stateMu.Unlock()
|
||||
slog.Error("failed to create Opus decoder", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.speakers[packet.SSRC()] = speaker
|
||||
}
|
||||
|
||||
s.stateMu.Unlock()
|
||||
|
||||
if err := speaker.write(packet.Sequence(), packet.Opus); err != nil {
|
||||
slog.Debug("failed to decode voice packet", "err", err, "ssrc", packet.SSRC())
|
||||
}
|
||||
|
||||
s.setSpeaking(session.User(packet.SSRC()), string(packet.Opus) != voice.Silence)
|
||||
}
|
||||
}
|
||||
|
||||
// send sends what the microphone captures to the voice channels that session joins, while it is loud enough.
|
||||
func (s *Session) send(session *voice.Session) {
|
||||
encoder, err := opus.NewEncoder(opus.EncoderConfig{SampleRate: sampleRate, Channels: channels})
|
||||
if err != nil {
|
||||
slog.Error("failed to create Opus encoder", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
me, _ := s.state.Cabinet.Me()
|
||||
|
||||
var (
|
||||
pcm []byte
|
||||
samples = make([]int16, frameSamples)
|
||||
packet = make([]byte, 4000)
|
||||
silence = []byte(voice.Silence)
|
||||
// quiet is how many frames ago the last that was loud enough was.
|
||||
quiet = hangover
|
||||
)
|
||||
|
||||
for captured := range s.mic {
|
||||
for pcm = append(pcm, captured...); len(pcm) >= frameBytes; pcm = pcm[frameBytes:] {
|
||||
quiet++
|
||||
for i := range samples {
|
||||
samples[i] = int16(binary.LittleEndian.Uint16(pcm[2*i:]))
|
||||
if int(samples[i]) >= s.threshold || -int(samples[i]) >= s.threshold {
|
||||
quiet = 0
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
switch {
|
||||
case quiet < hangover:
|
||||
var n int
|
||||
if n, err = encoder.Encode(samples, packet); err == nil {
|
||||
_, err = session.Write(packet[:n])
|
||||
}
|
||||
case quiet < hangover+5:
|
||||
// A few frames of silence are for what was said not to be interpolated with what is said next.
|
||||
_, err = session.Write(silence)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Debug("failed to send voice packet", "err", err)
|
||||
}
|
||||
|
||||
if me != nil {
|
||||
s.setSpeaking(me.ID, quiet < hangover)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
internal/voice/session_no_voice.go
Normal file
27
internal/voice/session_no_voice.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
//go:build no_voice
|
||||
|
||||
package voice
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/ayn2op/arikawa/v3/discord"
|
||||
"github.com/ayn2op/arikawa/v3/state"
|
||||
)
|
||||
|
||||
// Session joins no voice channel.
|
||||
type Session struct{}
|
||||
|
||||
func New(*state.State, float64) *Session { return &Session{} }
|
||||
|
||||
func (*Session) Updates() <-chan struct{} { return nil }
|
||||
func (*Session) Status() Status { return Status{} }
|
||||
func (*Session) Speaking(discord.UserID) bool { return false }
|
||||
func (*Session) Latency() time.Duration { return 0 }
|
||||
func (*Session) Leave() error { return nil }
|
||||
func (*Session) Toggle(bool) error { return nil }
|
||||
|
||||
func (*Session) Join(discord.ChannelID) error {
|
||||
return errors.New("discordo was built without voice")
|
||||
}
|
||||
120
internal/voice/speaker.go
Normal file
120
internal/voice/speaker.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
//go:build !no_voice
|
||||
|
||||
package voice
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tphakala/go-opus/opus"
|
||||
)
|
||||
|
||||
const (
|
||||
sampleRate = 48000
|
||||
channels = 2
|
||||
// frameSamples and frameBytes are the sizes of 20ms of PCM.
|
||||
frameSamples = sampleRate / 50 * channels
|
||||
frameBytes = frameSamples * 2
|
||||
|
||||
// cushion is how much is buffered before it is played, so that packets arriving unevenly do not make gaps.
|
||||
cushion = 3 * frameBytes
|
||||
// maxBuffered is how much is buffered before the oldest is dropped rather than fall behind.
|
||||
maxBuffered = 10 * frameBytes
|
||||
// maxLost is how many packets in a row are made up for.
|
||||
maxLost = 3
|
||||
)
|
||||
|
||||
// speaker decodes and buffers what one user says, to be mixed into what is played.
|
||||
type speaker struct {
|
||||
mu sync.Mutex
|
||||
decoder *opus.Decoder
|
||||
pcm []int16
|
||||
buf []byte
|
||||
// playing is whether the cushion was buffered and what is left of it is being played.
|
||||
playing bool
|
||||
// seq is the sequence number of the last packet, and last when it arrived.
|
||||
seq uint16
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// newSpeaker returns a speaker whose first packet has the sequence number seq.
|
||||
func newSpeaker(seq uint16) (*speaker, error) {
|
||||
decoder, err := opus.NewDecoder(sampleRate, channels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 120ms is the longest an Opus packet can be.
|
||||
return &speaker{decoder: decoder, pcm: make([]int16, 6*frameSamples), seq: seq - 1}, nil
|
||||
}
|
||||
|
||||
// decode appends what packet decodes to, or a made up frame if it is nil.
|
||||
func (s *speaker) decode(packet []byte) error {
|
||||
pcm := s.pcm
|
||||
if packet == nil {
|
||||
pcm = pcm[:frameSamples]
|
||||
}
|
||||
|
||||
n, err := s.decoder.Decode(packet, pcm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, sample := range pcm[:n*channels] {
|
||||
s.buf = binary.LittleEndian.AppendUint16(s.buf, uint16(sample))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// write buffers the packet with the sequence number seq.
|
||||
func (s *speaker) write(seq uint16, packet []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Make up for the few packets that never arrived. One that arrives late or twice is not worth playing.
|
||||
gap := seq - s.seq
|
||||
if gap == 0 || gap > 1<<15 && time.Since(s.last) < time.Second {
|
||||
return nil
|
||||
}
|
||||
|
||||
if gap > 1 && gap <= maxLost+1 {
|
||||
for range gap - 1 {
|
||||
if err := s.decode(nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.seq, s.last = seq, time.Now()
|
||||
|
||||
if err := s.decode(packet); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(s.buf) > maxBuffered {
|
||||
s.buf = s.buf[len(s.buf)-cushion:]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mix adds what is buffered to output, once the cushion is.
|
||||
func (s *speaker) mix(output []byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.playing = s.playing || len(s.buf) >= cushion; !s.playing {
|
||||
return
|
||||
}
|
||||
|
||||
n := min(len(output), len(s.buf))
|
||||
for i := 0; i+1 < n; i += 2 {
|
||||
sum := int32(int16(binary.LittleEndian.Uint16(output[i:]))) + int32(int16(binary.LittleEndian.Uint16(s.buf[i:])))
|
||||
binary.LittleEndian.PutUint16(output[i:], uint16(max(-1<<15, min(1<<15-1, sum))))
|
||||
}
|
||||
|
||||
s.buf = s.buf[n:]
|
||||
s.playing = len(s.buf) > 0
|
||||
}
|
||||
11
internal/voice/voice.go
Normal file
11
internal/voice/voice.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Package voice joins voice channels, plays what is said in them and sends what the user says. Built with the no_voice tag, it does none of that and needs no C compiler.
|
||||
package voice
|
||||
|
||||
import "github.com/ayn2op/arikawa/v3/discord"
|
||||
|
||||
// Status is what a Session is doing.
|
||||
type Status struct {
|
||||
// ChannelID is the voice channel joined or being joined, if any.
|
||||
ChannelID discord.ChannelID
|
||||
Joined bool
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue