Compare commits

175 Commits

Author SHA1 Message Date
2351b174f8 Leave all servers in parallel on shutdown
Some checks are pending
test / test (push) Waiting to run
Quitting tells every server goodbye at once, so leaving takes as long as
the slowest server rather than all of them together. Desktop and Android
both go through ServerSession.shutdownAll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:55:15 +00:00
5fd2a612b9 Show the local client's state in the Android connection notification, like the tray icon
The notification follows the server holding the microphone, as the desktop tray
does, and draws its state: the Material glyph in the status bar, the chosen
pack's art as the large icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:21:42 +00:00
2d83eafffd Android: Design settings page, Contacts in Settings, server icons in the switcher
Appearance options move to their own Design page. Contacts is the first
Settings entry instead of a top-bar button. The top bar and the server
switcher show each server's own icon, else the pack's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:12:16 +00:00
4911542d39 Add a Permissions menu with privilege keys: list, create, remove and use
Permissions > Use Privilege Key redeems a key (tokenuse). Permissions >
Privilege Keys opens a per-tab window listing the server's keys with
their group, channel, creation date and description; keys can be created,
removed, copied and reloaded. The create dialog takes a server group, or a
channel group in a chosen channel, plus a description, offers only groups
we have the power to add members to, and shows each new key for copying.

tokenlist and tokenadd answer with notifytokenlist / notifytokenadd ahead
of the command's completion, so core collects them the way it does the
ban list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:00:27 +00:00
2c1b37ee30 Show servers' icons on bookmarks, and their host button in the toolbar's corner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:28:35 +00:00
cf1db61a4a Kick, ban and set groups from the Android client sheet, with a ban list
The client sheet offers kicking from the channel or the server (asking to
confirm, with an optional reason), banning with a reason and duration, and
the client's server groups and channel group. The server sheet opens the ban
list: search, own bans only, and adding, editing and deleting bans, with the
reason presets the desktop keeps.

Kick and ban powers, like move power, never come with the needed
permissions, so core now asks for all four in one piped permget. The ban
permission checks, a ban's summary, the duration units, the group choices
for a client and the kick reason limit move into core, and Swing uses them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:27:29 +00:00
76f6a9feed Drop the Options dialog's page banner
It only repeated the page selected in the sidebar, and took vertical
space from the page itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:11:43 +00:00
e06bb82299 Lay out the Options dialog like TeamSpeak 3's
A sidebar of pages with large icons replaces the tabs, and each page
opens under a banner with its title, subtitle and icon. OK, Cancel and
Apply sit below, and the dialog reopens on the last page used.

The pages follow TS3's: Application (muted-microphone warning, reported
client version), myTeamSpeak, Playback, Capture, Design, Hotkeys,
Contacts, Chat and Notifications. Capture now holds everything from the
microphone to the server: the device and gain, activation with the
microphone test, signal processing and the encoder, laid out in one
grid so its columns line up. Forms are grouped under section headings.

Cancel now goes back to what was last applied, and closing the window
cancels too, so a theme or icon pack being tried out is put back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:07:13 +00:00
0ad56671af Stop double-click from expanding channels in the server tree
Double-click joins a channel; expanding is left to the handles and the
keyboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:07:13 +00:00
d2de76f534 Zoom chat and descriptions with Ctrl+mouse wheel
Text and pictures in the chat tabs and the info panel zoom in browser
steps (50-300 %), keeping the line at the top in view. The chat and the
info panel each have their own level, shared by every server tab and
saved in the settings.

The HTML documents now take their fonts from a stylesheet that applies
the zoom. The old ScaledStyleSheet never reached the document, and
FlatLaf already applies the interface scale, so it only zooms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:44:04 +00:00
0ff0919db2 Keep the server tab strip in step with theme switches
The strip is out of the component tree while one server is open, so a
look-and-feel switch missed it and it came back in the old theme's colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:31:43 +00:00
bda114d2c0 Let admins delete other clients' avatars
With b_client_avatar_delete_other, the client's context menu and the
menu on their avatar offer "Delete Avatar", as in TeamSpeak 3. The
server takes the deletion as ftdeletefile on /avatar_<unique id> (not
the stored file's name, which it refuses with "convert error") and
clears the client's avatar flag for everyone. Our own avatar's file is
now deleted the same way, which only such clients are allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:27:41 +00:00
b1890cbf79 Set and delete our avatar from the Self menu and the avatar's menu
As in TeamSpeak 3, Self offers "Set avatar…", which picks an image and
uploads it, and "Delete avatar" while we have one; the old avatar
dialog is gone. Setting (and stealing) is offered only while the server
grants i_client_max_avatar_filesize. Right-clicking our own avatar in
the info panel offers the same two beside saving it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:01:16 +00:00
e7d338ce28 Save or steal other people's avatars from the info panel
Right-clicking a client's avatar offers to save it to a file or to take
it as our own avatar on that server, as the Android picture viewer does.
Our own avatar only offers saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:56:11 +00:00
8fc7be826c Let the noise removal slider move freely like the AGC boost slider
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:51:34 +00:00
662e031b6b Find in the channel tree and the chat on Android
A find button in the top bar opens a find bar over the tree (clients or
channels) or the conversation on screen, with match stepping, highlights,
only-matches filtering and the desktop's matching options. The tree's
match rule moves to core TreeMatch, which the desktop now uses too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:54:02 +00:00
66ef62913a Find a client in the channel tree from their sheet on Android
Offered, as on desktop, when the sheet was opened from outside the tree
(a name in the chat): expands the client's channels, scrolls to them and
flashes their row amber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:13:49 +00:00
f80f619a65 Put friend and block on one line in the Android client sheet; remove contacts from their page
The client sheet keeps only "Edit contact" for contacts. The contact page's
remove button takes the remove-friend/foe icon instead of the trash can.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:52:41 +00:00
7f30a27d73 Open avatars and description pictures full size on Android; fold minor client facts
A client's sheet now shows only their server and channel groups, with
the rest (contact, talk power, version, microphone, unique ID, ...)
behind "more…". Core marks those facts minor, for frontends short of
room; groups without a name or an icon are left out on both clients.

Tapping an avatar or a picture in a description opens it on black, to
pinch or double-tap zoom. Its menu saves it to Pictures/TeamSpeak, takes
it as our avatar (not on our own), copies its address, and opens the
link it sat in, which a tap used to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:27:50 +00:00
e799218444 Show clients' country flags beside their names, and optionally in the tree
The server places each client in a country (client_country, from its GeoIP
database); a client's details now show that country's flag left of their
name, as TeamSpeak's info frame does, on both clients. "Show country flags
in the channel tree" (off by default, as TeamSpeak's EnableCountryFlags)
also puts it last on client rows.

The flags are TeamSpeak's countries.zip, which now ships with the icon
packs; core's CountryFlags reads it, else a TeamSpeak 3 install's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:40:44 +00:00
4ca5534ef4 Ship TeamSpeak's icon packs with both clients
Default, Default Colored 2014 and Default Mono 2014 now come inside the
desktop JAR and the APK, so the default pack is there without a TeamSpeak 3
install; before, a desktop without one had no icons at all. As with the
sound packs, the builds take them from the TeamSpeak 3 client next to the
repository (ts3.client.dir to point elsewhere) and ship none without it.

Core reads them from the classpath in memory; a pack of the same name found
on disk, in an install or imported, still comes first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:16:48 +00:00
af0564702e Show pictures in descriptions, or links to them when turned off
[img] in a channel, client or server description is now a picture on both
clients, as TeamSpeak shows it; chat keeps it a link. Only web addresses
become pictures. While one loads it is TeamSpeak's loading icon; one that
cannot be had is its address behind the broken-picture icon.

"Show pictures in descriptions" (desktop: Chat settings; Android: Appearance)
turns them into links behind a picture icon instead, fetching nothing. A
picture that was itself a link keeps that link beside it.

Core's PictureDownloads fetches them for both clients: kept on disk for a
month after last shown, at most 8 MB, redirects followed across http and
https, and on Android plain http asked for over https. The desktop draws
them narrowed to the info panel, Android at a pixel to a dp, animated GIFs
playing on both; long addresses are shortened to host and file name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:06:13 +00:00
fd86d9e638 Show server, channel and group icons and client avatars on Android
The tree lines icons up at the right of each row as TeamSpeak does: the
server's own icon, a channel's icon, and a client's server group icons then
their channel group's. The details list each group with its icon, and the
client sheet shows the avatar; an animated GIF plays while it is on screen.

TeamSpeak's default group icons come from the icon pack, as on the desktop;
a server's own uploads are downloaded by core's repositories and decoded
once per server, redrawn as they arrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:34:52 +00:00
b678805c37 Offer the server groups we have the power for, not only those needing none
canToggleGroup compared a group's needed member add/remove power with our own
i_group_needed_member_*_power, which the server never reports for us: our
power is i_group_member_add_power and i_group_member_remove_power. An admin
was only ever offered groups that need no power at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:21:43 +00:00
b1ad403fdf Move clients to another channel or our own from the Android client sheet
"Move to channel…" closes the sheet and marks the client, and the next
channel tapped is where they go; a bar above the tree names them and cancels.
"Move to own channel" shows while they are elsewhere.

Both show only with some i_client_move_power. The server's needed
permissions never carry it, even for an admin, so core asks for it with
permget on connect and whenever our own groups change; a refusal (guests may
not ask) counts as none. Whether it beats the client's needed move power only
the server knows, and it refuses when not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:16:21 +00:00
cd02e4b2d8 Show server, channel and client details on Android, and the server's on the desktop
Core's Details says what the info box tells, as TeamSpeak's info frame does:
labelled facts, then a BBCode description. The desktop info panel now lays it
out instead of building its own, and selecting the server row shows the
server: address, version, clients, channels, creation date and its welcome
and host messages, from the virtualserver_* properties kept since initserver.

On Android the channel and client sheets show the same details, asking the
server for a missing channel description or a client's variables; tapping
the server row opens a server sheet. Descriptions render as TeamSpeak's do:
BBCode now knows [hr], [left]/[right]/[justify], numbered and lettered lists
and tables, which Android's HTML parser approximates — rules as dividers,
table rows as lines, list items numbered, font sizes as big/small. Links are
blue whatever the wallpaper.

Push-to-talk in the bottom bar follows whichever server holds the microphone,
and a server without it keeps its button to take it in a fixed slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:52:28 +00:00
9b1aa45611 Connect to several servers at once on Android, in a denser TeamSpeak-like layout
The server name in the top bar opens the servers the app is on, "Connect…" for
another one and "Disconnect from current". Only one server captures with the
microphone at a time, handed over by core's Sessions as on the desktop; a
server without it shows a button to bring it there. Push-to-talk, the floating
button and the level meter follow the server holding it.

Each server has its own controller (session, tree, chats, network recovery);
the app-wide one keeps what they share. Private message notifications carry
their server, as client ids repeat across servers.

The layout follows TeamSpeak's own: 26 dp tree rows with smaller icons and
text, a lower top bar, and no tabs — the chat is a button in the bottom bar,
whose buttons keep fixed places around the push-to-talk button's space.
Holding the mute button switches between push-to-talk and voice activation.

A server that drops the connection now lets go of the microphone too, so a
reconnect cannot capture alongside the server that took it over meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:08:36 +00:00
9f2e5087ce Stop two servers taking turns claiming a contact's last sighting
A contact on two servers at once was marked seen by each in turn: a sighting
announced a contact change, every connection re-applied its contacts, and
each re-application recorded the contact on its own server — another change,
round and round, rewriting contacts.txt every time. Re-applying now only marks
a contact that was never seen; the last sighting moves when the client appears.

Sightings no longer write the file on their own either: they reach it in one
save half a minute later, or with the next edit's save, and are flushed when
the client closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:08:36 +00:00
1dda20d1b2 Manage identities on Android, pick one per bookmark, and give identities their own nicknames
Settings -> Identities lists them and creates, imports (an identity file or a
TeamSpeak 3 settings.db), exports, renames and removes them, picks the
default and improves an identity's security level; the search runs on until
reached or stopped, whichever page is on screen. The Connect screen and each
bookmark choose the identity to connect with.

An identity keeps a nickname and phonetic nickname, in TeamSpeak's own INI
keys, on both clients. A connect goes by the bookmark's nickname, else the
identity's, else the global one; the phonetic one is sent in clientinit.
Imported bookmarks no longer copy their identity's nickname.

IdentityStore is now safe to share with the thread a connect resolves its
identity on, and imports and exports through streams for Android's document
picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:17:14 +00:00
6aa4be38ee Fold the Android noise suppression toggle into its slider, and let the muted-talk beep be turned off
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:42:43 +00:00
f1e7c93594 Float a push-to-talk button over other apps on Android
Held, it talks; a double tap whose second touch is held drags it. It shows
while the app is off screen in push-to-talk mode, half see-through at rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:38:59 +00:00
4b50f37735 Ship the Android sound packs as Opus
The build encodes TeamSpeak's packs to Ogg Opus at 64 kbps before bundling
them, which takes the sounds from 21.3 MB to 2.0 MB, in the APK and again
once unpacked on the phone. The encoding runs the desktop code's libopus
on the build machine, through a build-only :desktop module compiled from
the same sources as the Maven one.

Both clients play Opus sounds: the sound player (now PackSoundPlayer, as it
no longer plays only wave files) decodes Ogg Opus with the platform's
libopus, and a pack's play("x.wav") finds x.opus, so converted packs keep
their scripts. SoundPackConverter turns any pack folder into Opus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:12:50 +00:00
02ff63341e Notify of pokes and private messages on the desktop
While none of the client's windows has the focus, a poke or a private
message shows a desktop notification, which the desktop's notification
centre keeps. A private conversation keeps one notification, updated as
messages come; clicking it brings the window forward with that chat open,
and they all go once a window of the client is in front again.

On Linux they go to the freedesktop.org notification service, spoken to
over the session bus by a small D-Bus client of our own on Java's Unix
sockets, so no library or native code is involved; Plasma also takes a
reply typed into the notification. Elsewhere they are AWT tray messages,
which Windows and macOS put in their notification centres. The options
can turn them off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:20:51 +00:00
a6efc0b8da Notify of pokes and private messages on Android, with TeamSpeak's sounds
While the app is out of sight, a poke or a private message posts a
notification; a message's one gathers the conversation, takes a reply
inline and opens its chat. The notifications are silent, as the sound
pack already plays TeamSpeak's own sound for either.

The build bundles the sound packs of the TeamSpeak 3 client found next
to the checkout (or at ts3.client.dir / TS3_CLIENT_DIR), unpacked into
the profile once per app version. Sound Settings picks the pack and its
volume, and speech packs talk through the phone's text-to-speech.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:12:16 +00:00
002b1ac7ce Dock the tray icon once the window is on screen
Docking waits for the panel to adopt the icon, which held up the main
window; wait at most half a second for it before falling back to AWT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:25:44 +00:00
75df46a03a Narrow the desktop contact list
Each contact shows just the date it was last seen, without the client
dot and at half the usual nesting; the full time and server move under
the phonetic nickname as "Last seen … on …".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:39:15 +00:00
fedb7b4385 Show all of a contact's options at once on desktop
The contacts window drops its tabs for one column of short groups beside
the list: contact type, display and ignore, where whisper becomes a
"Deny whisper" box, since allowing is the default. The names get their
labels on a line of their own, and the window is never shorter than the
options, leaving the list the only thing that scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:52:56 +00:00
570feffc25 Keep contacts on Android
A contacts page lists friends, blocked and neutral clients with when and
where each was last seen, and opens each one's settings: custom and
phonetic nickname, type, how the name is shown, what to ignore, whether
it may whisper, a volume change, and its unique ID, copied on a tap.
Changes are stored at once. Another page holds the defaults new contacts
start from, and a TeamSpeak 3 settings.db can be imported.

A client's sheet on a server adds them as a friend or blocks them, or
edits and removes the contact they already are. Everything else runs on
core's contact store as on desktop, so a change reaches the server's
playback and tree straight away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:52:56 +00:00
5c0a5e4bcf Sort the Android settings into categories
Settings opens on a list of categories, for now Sound Settings (devices,
transmission, voice activation, processing, playback) and myTeamSpeak,
each a page of its own, with the icon pack beneath them. The nickname
field goes, as the connect screen already keeps it. The rows the pages
share move into their own file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:52:56 +00:00
e7741e8b34 Give the Material pack every icon TeamSpeak's packs have
The rest of default_colored_2014's keys get Material counterparts:
talk power, whisper, recording, text formatting, identities, add-ons,
permissions and the like. Ping levels are drawn as signal bars, since
Material's own tell their levels apart by a hairline, and the default
group icons as lettered badges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:43:07 +00:00
cdf81f3258 Share icon packs between desktop and Android
The Android app's Material icons become a TeamSpeak-format pack shipped in
core, which the desktop offers next to the TeamSpeak packs. Android now
draws its icons from packs too and imports TeamSpeak's own zips, falling
back to the Material pack for what they lack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:39:42 +00:00
a7aa122728 Sign in to myTeamSpeak and import what the account synchronises
The myTeamSpeak client protocol and its end-to-end encryption are
reverse-engineered (docs/myteamspeak): an HTTP POST of a framed protobuf,
a PBKDF2-HMAC-SHA512 login token and account key, an AES-GCM wrapped
item key and AES-CTR items with a SHA-512 trailer.

Options on desktop and Settings on Android gain a myTeamSpeak page: sign
in and out, and the account's bookmarks and identities, each picked for
import and marked when already here. The client stays signed in the way
the official one does, keeping the derived token and key rather than the
password; each read signs in afresh, and a sign-in the server no longer
takes signs out. Nothing is written to the account.

Items are the same Item_Data as the local TeamSpeak store, so they go
through the existing decoder and importer; a bookmark chosen alone brings
the identity it connects with, found by UUID or, as some name it, by
the identity's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 01:07:17 +00:00
73a15a8cfc Let AGC raise the noise floor further to bring up a distant voice
AGC2 won't amplify the background noise above -50 dBFS, which also caps
the gain for a voice barely above that noise: a distant talker ended up
5 dB quieter than a near one with noise suppression on, 12 dB with it
off. A "Boost quiet speech" setting (0-20 dB, default 0) raises the cap,
live, on desktop and Android.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 22:08:50 +00:00
491cbcb259 Leave the server when the app is swiped away
The process lived on without a screen, or was killed without a word, and
the server held our identity as a ghost until it timed out, refusing a
reconnect in the meantime as a clone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:31:50 +00:00
5fa7024bcb Follow the phone from one network to another
A server knows its clients by address, so moving between Wi-Fi and mobile
data leaves the connection with nothing to do but time out. The process's
sockets are now bound to the network a connection was made over: on a
change, the goodbye still leaves through the old network while it lasts,
and the connection is made again over the new one, back into the channel
we were in. A drop because the network vanished counts as a change too.

A server still holding the old connection refuses the new one as a clone
until it times out, so attempts are retried with a growing pause; a ban
ends it, and so do five minutes without getting back on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:31:41 +00:00
c8e99c8946 Show the microphone level for tuning voice activation
The settings show the level on TeamSpeak's scale, lit while it would be
sent, with the volume gate's threshold marked as the slider moves. While
connected it listens to the connection's own microphone, so it shows
exactly what goes out; otherwise it runs a test microphone of its own for
as long as the settings are on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:31:31 +00:00
62ced89701 Pick the Android audio devices, microphone preset and playback usage
Microphone and speaker are chosen separately, so a Bluetooth headset can
play in media quality while the phone's own microphone listens, instead of
the headset dropping into its call profile. Devices are kept by type and
address: Android hands out new ids whenever one reconnects, and a line that
lost its device comes back to it when it returns.

The microphone preset (voice recognition by default, or the platform's
call processing, unprocessed, ...) and whether voices play as media or as
a call are Android's own settings. Changing either reopens the open
streams in place. Communication mode, and the headset's call link that a
Bluetooth microphone needs, are held only while a stream needs them, and
are set up before that stream opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:31:22 +00:00
2281c125fa Keep the error id a server refused the last connect with
The message alone does not tell a ban from a refusal worth retrying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:30:23 +00:00
25ca586a50 Let a disconnect give up a connect still under way
Disconnecting only acted once connected, so a connect that was stuck or
retrying could not be called off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:30:23 +00:00
ed526897dc Switch audio devices without reopening the connection
A new input device used to wait for the next connect, and the shared mixer
kept its line on the old output until it went idle for good. The microphone
now reopens on the new device as the settings are applied, and the mixer
moves its line over when the output device changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:30:23 +00:00
967fcadf77 Keep chat drafts per conversation and open each at its newest line
Switching conversations dropped the message being written, and showed
the next one at the scroll position of the last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:12:50 +00:00
70c6eb769c Say why a connection ended, and mark the channel we are in subscribed
- A failed connect keeps its reason in the status instead of a bare
  "Disconnected", as does a disconnect the server gave a reason for.
- The server never announces the subscription to the channel we start
  out in, although it keeps us subscribed after we leave it, so the
  channel we enter counts as subscribed.
- A client connecting or leaving the server is logged as that, not as
  switching from or to "channel #0": the server gives both the same
  reason, only with no channel on one side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:12:50 +00:00
f2c9143997 Pick up ts3j's refused-connect reason
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:12:50 +00:00
941365cab0 Give the Android app its real UI
- Connect screen with an address or a bookmark, which can be added and
  removed there.
- Server screen: the channel tree with each client's status, folding
  channels, joining (asking for a password where needed) and
  subscribing from a channel's sheet; private message, poke and volume
  from a client's sheet.
- Chat: the server log, the channel and private conversations with
  their history, unread marks, BBCode and clickable client, channel and
  web links. Chat lines are the HTML the desktop shows, turned into
  styled text.
- Voice bar: mute, deafen, away, and a hold-to-talk button in
  push-to-talk mode. The microphone permission is asked for on connect.
- Settings: nickname, transmission mode, voice activation, noise
  suppression, gain control and playback volume, in effect at once.

The session's state reaches Compose as immutable snapshots: the tree
at most every 30 ms, rebuilt only when channels or clients changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:21:51 +00:00
d83ba1145b Make a parsed spacer a value
So a frontend comparing snapshots of the tree sees an unchanged spacer
as unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:21:51 +00:00
35d6427456 Apply voice settings to a live microphone in one place
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:10:35 +00:00
d206b12a30 Move spacers, client status and chat entries into core
The Android client needs them too: which state a client's icon shows,
how spacer channels are named, and the HTML a chat line is written as.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:09:43 +00:00
c5a49fc7b3 Avoid JDK APIs newer than Android's minSdk 33 in core
Files.readString/writeString (API 36.1), Path.of, ByteBuffer.slice(int, int)
and Matcher.appendReplacement(StringBuilder) (API 34) threw NoSuchMethodError
on Android; connecting crashed in ChatLogStore. Each replacement keeps the
original desktop code in a comment above it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:05:39 +00:00
5d67b0c1e3 Record what came of the codebase audit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:39 +00:00
51f19c2d03 Bring the README's architecture and features up to date
The architecture tree predated the session, chatlog, contacts, text,
file transfer and voice pipeline packages, and named a backend that no
longer exists. The feature list still described the first speech
detector and processing chain rather than the WebRTC ports, and knew
nothing of the jitter buffer; the limitations listed file transfers,
avatars and administration as missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:26 +00:00
85c3ae6c8d Require Java 25, not 26
Nothing in the desktop client needs more than the FFM API, final since
Java 22. It builds, passes its tests and runs on OpenJDK 25, the current
LTS, which reaches more distributions. CI now tests on that minimum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:26 +00:00
acb3efabd8 Pick up ts3j's DNS test that works offline
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:26 +00:00
b6c5a772e6 Run the tests on every push
A Gitea Actions workflow that builds ts3j and the client and runs every
test; none needs a display or the native Opus library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:26 +00:00
71ac2c817c Test the channel tree, the event handler and settings
- the tree's sibling chain: arrival order, broken links, removal,
  reordering and reparenting, orphans, client order, path lookup
- the event handler, fed events parsed from the server's own wording:
  joins, moves, leaves, partial and clearing updates, server groups,
  and a channel's life from creation to deletion
- settings: saving and loading back, unknown values, and the one-time
  conversion of the old dBFS voice threshold

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:26 +00:00
bf07f0bf49 Move what the servers share out of the main window
Which server captures with the microphone, handing it over, push-to-talk
and key presses reaching the right microphones, and away state and
nickname applied everywhere were all worked out in MainFrame. They move
to a Sessions class in core, next to ServerSession, where another
frontend can use them; the window keeps only the tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:26 +00:00
f30cecf51c Keep channels the user collapsed collapsed
Every tree rebuild, which any join, leave or talk-power change causes,
expanded every row again, so a collapsed channel reopened within
seconds. The tree now remembers which channels were collapsed and
opens everything else; reopening one restores what is below it. While
the find bar filters, everything opens so every match shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:26 +00:00
ae8d1f7083 Fail a download whose file server stops sending
The data socket had no read timeout, so a file server that went quiet
left the transfer, and its thread, waiting until the user cancelled.
Thirty seconds without data now fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:26 +00:00
48a0032848 Parse every enum setting with the one generic parser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:26 +00:00
3cdade9ad7 Keep the profile private and write it all at once
Settings, bookmarks (server passwords) and identities (private keys)
were written in place with the default permissions, readable by every
user on the machine, and a crash mid-write left a truncated file that
the next start silently replaced with defaults.

Every profile file is now written to a private temporary file, synced
and moved over the old one in one step. The profile folder itself is
made accessible to its owner only, which also covers files written
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
d7261c906d Let chat logs be turned off or kept elsewhere
Chat logs always went into the TeamSpeak 3 client's own folder. A new
Chat page in Options can turn them off, or point them at another
folder; left empty, they are still shared with the TeamSpeak 3 client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
1a3a3e37c1 Write both kinds of chat message through one method
appendMessage and appendPrivateMessage built the same entry twice; they
now differ only in which log they pick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
a5c4ba1eb5 Load chat history from the end of the log only
Chat logs grow forever, and every connect and every private chat opened
read the whole log to keep its last 1000 entries. History now reads the
last 4 MiB, which holds far more than that; searching still reads the
whole log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
f9b2f4d7cb Keep every line of a multi-line message in chat history
The text log has a message's line breaks in it, but history was read one
line at a time, so every line after the first failed to parse and was
dropped. A line that starts no entry of its own now continues the
message before it, which also recovers what earlier versions wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
8e9c461424 Wait for the capture loop when the microphone stops
stop() closed the encoder and cleared the transmit state while the
capture loop could still be in its last frame, which could turn the
transmit indicator back on, or overlap the loop of a quick restart on
the same speech detector and processing chain. It now closes the line
to wake the loop and waits for it to finish before tearing down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
12ca585d90 Open a new line when a speaker's playback line dies
Both desktop backends dropped writes to a line whose device had gone
away, and the per-speaker playout ignored any failure, so that speaker
stayed silent for the rest of the session. A write to a dead line now
throws; the playout drops that line and the speaker's next packet
opens a fresh one. Sound effects and the microphone test's loopback
reopen their line the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
6234ec6d97 Give back a speaker's line after 30 s of silence
Every client that ever spoke kept an open playback line and a thread
until it left the server. A speaker silent for 30 s now has both
released, and its next talk burst opens them again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
0083dc8ad6 Play incoming voice through TeamSpeak's adaptive jitter buffer
Each speaker's stream held a fixed 60 ms and gave up after 200 ms of
concealment, so a jittery connection concealed constantly and a stall
ended the burst. It now runs through a port of the libspeex jitter
buffer the official client links, configured and driven as it does:

- the delay adapts to the measured jitter, aiming for at most 1% of
  packets arriving late
- a speaker keeps one timeline, never re-anchored between talk bursts,
  so what the buffer learned carries over; only 5 s without packets
  drops it
- the talker's stop marker never enters the buffer; playback ends when
  it reaches it, and its slot is played as silence, not concealed

A pull now always hands out one 20 ms frame: what a longer packet or a
delay increase produces beyond that is played on the following pulls,
which Android's mixer used to cut off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
7cb4f47a81 Show an avatar, description or away message being cleared
Client and channel updates were only applied when the field had a
value, since ts3j reads a missing field back as an empty string. A
removed avatar, an emptied description or a cleared away message
arrives as the field without a value, so they stayed as they were. Such
fields are now applied whenever the update carries them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
8c735a05b8 Look permissions up by name directly
selfPermissionValue scanned every permission the server lists, several
times per context menu. The model now keeps a name index next to the
id one. Also drops primaryServerGroupName, which nothing calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
67f8f46cab Publish the channel tree's lists whole instead of refilling them
buildTree() cleared and refilled the children and client lists inside
the shared channel nodes, so a UI still walking the previous tree could
see them change under it. It now builds new lists and publishes them
unmodifiable. The node fields, written by the event and audio threads
and read by the UI, are volatile.

Whether a channel is full is now counted from the model, rather than
from a tree that may not have been rebuilt since the last join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
665316d515 Hand the microphone between tabs in the order it was asked
Each hand-over ran on a fresh thread, so two quick tab switches could
start the new tab's capture before the old one stopped, or stop it after
it started. They now run one after another on a single thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
5b1809cc2d Hand out hotkey bindings as a snapshot
Hotkeys.all() returned its synchronised list, and every reader had to
lock it while iterating: a lock owned by one class and taken by four
others. It now returns a copy, and the lock stays inside Hotkeys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:25:25 +00:00
eeccb495eb Name chat logs by the server's unique id for guests too
The id came from initserver, which never carries it, then from
serverinfo and servergetvariables, which a guest may not use; chat logs
only worked with admin rights. ts3j now derives it from the server's
handshake key, as the official client does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:35:41 +00:00
d074ffaa7a Tell permission errors apart by the server's error id
The sound for a refused action was picked by looking for "permission"
or "insufficient" in the message, which also matched errors such as an
invalid permission id. The server's error ids for insufficient client,
group and permission power say it exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:31:28 +00:00
d28bb57598 Release a previous connection's resources on connect
A connect on an instance that was still connected, or still
connecting, replaced the socket and audio pipelines without closing
them. The fields shared between the connect, event, action and UI
threads are now volatile, and read once where they are used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:30:34 +00:00
f350b19d31 Run a connection's requests on its own pool
Each action started a thread that read the socket field once it ran; a
disconnect in between cleared it, and the user saw "Could not join
channel: NullPointerException". Actions now capture the socket when
they are issued and run on a pool that lives as long as the connection.
What fails because the connection went away is no longer reported as an
error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:29:23 +00:00
ba5de5a8a8 Give the importer test a fresh profile every run
The test home under target/ outlives a run, so the identity the first
run imported made every later run fail. The test now points AppDirs at
a temporary directory of its own, which also works when another test
resolved the profile first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:27:11 +00:00
f871a88bce Ignore the bundled TeamSpeak 6 client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:26:21 +00:00
72057ac1df Pick up ts3j's handling of commands that never get through
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:08:09 +00:00
95726d9a8d Pick up ts3j's fix for resends under current BouncyCastle
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:42:48 +00:00
b3ad3ceda0 Keep the Android connection alive in the background
The session moves from the connection screen's ViewModel into an
app-wide SessionController, and a foreground service keeps the process
running while connected:

- Its notification shows the server and status and offers Mute, Deafen
  and Disconnect.
- It declares microphone use only once the microphone is handed to the
  connection, as Android requires that permission to be granted then.
- It holds a low-latency Wi-Fi lock and a CPU wake lock, since voice
  goes out every 20 ms.
- When the system moves to another network, the session reconnects to
  where it was, as its socket stays bound to the old one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:28:36 +00:00
11a2a14c70 Let a session reconnect to where it was
ServerSession.reconnect() connects again to the last server with the
same identity and returns to the channel the client was in: what a
dropped connection or a changed network (Android moving from Wi-Fi to
mobile data) needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:26:03 +00:00
07aa7bd7f9 Don't report a channel join that leaving the server cut short
Connecting with a channel moves there once the channel list is in; if
the connection was left again before that, the move ran against the
closed socket and reported an error nobody could act on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:26:03 +00:00
086b913c8e Add the Android app: Gradle build, Oboe audio, connection screen
android/ builds core and ts3j from the same sources as the desktop's
Maven build, so the app shares all of the client's logic:

- audio: Oboe streams behind AudioCapture/AudioPlayback, with a lock-free
  ring between the real-time callback and the blocking Java side, and
  libopus 1.5.2 built with the NDK behind OpusCodec. Speakers are mixed
  onto one stream. Capture uses the VoiceRecognition preset, leaving
  noise suppression and gain to the client's own processing.
- app: a plain connection screen to try the port out - connect,
  microphone, mute, deafen, channel chat and a log - until the real UI.

Targets Android 13 and up (minSdk 33) on arm64 and x86_64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:24:21 +00:00
e211ffa7b6 Share libopus' encoder controls between bindings
libopus' constants and the encoder settings built on opus_encoder_ctl
(clamping, voice/music signal) move into core, so the Android binding
reuses them rather than copying them from the desktop's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:24:12 +00:00
75d9606231 Conceal a lost voice packet with 20 ms, not 120 ms
A lost packet was concealed by decoding "nothing" into a buffer sized
for the longest Opus frame, and Opus fills whatever it is given: every
lost packet played 120 ms of made-up audio in place of 20 ms, piling up
delay on that speaker's line.

Decoders now conceal through their own call that takes the length to
make up, and the stream asks for the length of the last packet it got.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:18:06 +00:00
cc0243a7c6 Pick up ts3j's SRV lookup that works without JNDI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:13:14 +00:00
fecb2ba8f4 Answer what the local client may do from the model
The Swing client menu decided on its own whether a group can be
(un)assigned and whether a client's channel can be joined, and the
server groups dialog borrowed the menu's check. These are rules about
the server, not about menus, so ServerModel answers them now and any
frontend asks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:10:54 +00:00
8f385b9343 Keep each server's session state in core
What a server tab knows beyond its views - the connection lifecycle,
title and status, the identity used, the microphone/speaker/away and
commander flags, chat sending and the channel lookups - moves from the
Swing ServerTab and ServerTabSelfState into core's ServerSession, so
another frontend gets the same behaviour without copying it.

ServerSession also takes over what the Swing event bridge decided on its
own: clearing the model on disconnect, naming the tab after the server,
showing senders under their contact names, and the chat's system lines.
The bridge now only updates views. SelfState moves along, while its
icons stay with the tray.

A server switch no longer needs to queue behind the UI thread: the
disconnect is reported synchronously, so the session is free again by
the time it reconnects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:09:23 +00:00
24f4b3813d Build core for Java 17
Android will run core as well, and its runtime stops at Java 17. Pinning
the release makes the compiler reject newer language features and APIs
there, while the desktop modules keep the newest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:03:42 +00:00
94748e27ee Play notification sounds from core
The sound player was a desktop class only because it decoded wave files
through Java Sound. A small RIFF reader replaces that, so the player
moves to core and every platform gets it from AudioBackend by default.

The reader takes integer PCM up to 32 bits and 32-bit float, in plain
or extensible headers. It decodes all 151 files of the TS3 sound packs
to the same samples Java Sound does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:03:08 +00:00
7396c4ca40 Move the voice pipelines into core
Capture processing, the jitter buffer and decoding were desktop classes,
so any other platform would have had to copy them. They now live in core
and reach the platform only through two interfaces:

- AudioIo lists devices and opens capture/playback lines; the desktop's
  AudioDevices implements it over PipeWire and Java Sound.
- OpusCodec creates encoders and decoders; the desktop binds libopus
  through the FFM API as before.

DesktopVoiceInput becomes CaptureVoiceInput unchanged in behaviour.
DesktopVoiceOutput splits into VoiceStream, one speaker's jitter buffer
and decoder, paced by whoever pulls it, and StreamingVoiceOutput around
it. Speakers reach the device either on a line each (the desktop, so
each shows up in the PipeWire mixer) or mixed onto one shared line, as
mobile audio APIs want.

The settings dialog's device lists and microphone test now go through
the AudioBackend instead of desktop classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:01:28 +00:00
3c389de36b Resolve the client's data directories in one place
Stores read their paths from AppDirs instead of user.home, so a platform
without a home directory can point them elsewhere at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:52:32 +00:00
1d61f458ae Process the microphone like TS3, and tell it about key presses
The capture path now runs the ported WebRTC chain in place of the
home-made noise, typing and gain stages, which are removed.

- As in TS3, the speech detector judges the raw microphone signal, while
  the level meter and volume gate see the processed one.
- Noise removal takes TS3's four levels (6, 12, 18 or 21 dB); a stored
  0..1 level falls back to TS3's default of 12 dB.
- Every key press from the global input hook reaches the connected
  microphones and the microphone test, so typing attenuation engages
  while the user types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:47:04 +00:00
de5f7da236 Port TS3's WebRTC capture processing to Java
TS3 pre-processes the microphone with WebRTC's audio processing module.
AudioProcessor ports the stages it enables, in APM's order, per 10 ms at
48 kHz:

- The high-pass filter APM forces on with noise suppression, then the
  three-band split and WebRTC's noise suppressor at TS3's four levels.
- The transient suppressor behind "Typing attenuation", which only acts
  while it is told about key presses.
- AGC2 adaptive digital, WebRTC's successor to the AGC1 that TS3 runs.

The tests compare against 16-bit outputs of upstream APM builds
(webrtc-audio-processing 2.1, and 1.3 for the transient suppressor).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:46:46 +00:00
ee0f15fb3c Stop tripping the server's flood protection with query commands
channelinfo, clientinfo, channellist and clientlist are query commands
that count heavily against anti-flood; clicking through the tree got
every command refused within seconds. Use what the official client does:

- Channel descriptions via channelgetdescription, whose answer arrives as
  an invoker-less channel edit.
- Client version and platform via clientgetvariables, answered as a
  client update.
- Connection info takes version and platform from the model and idle
  time from notifyconnectioninfo instead of polling clientinfo.
- Drop the channellist/clientlist re-read after connecting: the events
  during connect already carry everything, and more (groups, unique ids,
  away state).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:32:05 +00:00
b01ed43d60 Find clients and channels in the server tree
Ctrl+F in the tree opens a find bar below it that washes matching rows,
steps the selection through them and can prune the tree down to the
matches and the channels leading to them. ServerTreePanel becomes a
panel holding the scroll pane and the bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:09:45 +00:00
d05c04afb4 Find in chat with Ctrl+F
A find bar above the conversations highlights matches and steps through
them, or filters the selected tab down to matching messages. New lines
no longer pull the view away while it is open. Ctrl+F opens it from
anywhere the tree doesn't claim the shortcut.

Note tabs are now found by their scroll pane like other tabs, so sending
checks for a target instead of assuming every tab has one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:09:45 +00:00
a4057cd499 Add text queries and whole-log chat search
TextQuery holds a find bar's text and options (match case, whole word,
regex) and matches plain text; HtmlText strips a log entry down to it.
ChatLogStore can now search a whole log rather than only the history
loaded into a chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:09:45 +00:00
249430efad Hide query clients from the log and name server groups correctly
- Query clients were already left out of the tree, but their joins, moves
  and ping-timeout drops were still logged and could play sounds; TS3 keeps
  them out of sight entirely.
- The name field of notifyservergroupclientadded/deleted is the client's
  nickname, not the group's, so "added to server group" named the wrong
  thing. Resolve the group by id instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:08:18 +00:00
3f65e95afd Fix channel reordering and keep the channel tree in sync
- Sort a channel among its current siblings with channeledit channel_order,
  as the official client does; the server refuses channelmove within the
  same parent ("already member of channel").
- Relink siblings when a channel_order edit arrives: the server never
  re-announces the neighbours, so the tree drifted out of order after any
  reorder.
- Apply password, permanence and max-clients flags from channel list,
  create and edit events, and let an emptied topic clear the old one.
- Log moves like TS3: "..., new parent channel is X".
- Play identical sounds that start within 250 ms only once, so bursts
  (a channel deleted with its subchannels) don't stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:06:53 +00:00
4de3ad4cab Make the talking-while-muted beep optional
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:25:58 +00:00
03ea2c86c3 Give the window its own identity in Linux task bars
AWT names the X11 window class after the thread that first touched the
toolkit, so the client shared java-awt-EventDispatchThread with every other
Swing app. Set it to ts3j-client instead (the jar's manifest opens the AWT
internals this needs), and install a matching .desktop launcher and icon in
the user's data directory: without a launcher claiming the class, Plasma falls
back to the process's command line and files the window under Java.

The window icon is now offered at several sizes instead of 16 px only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:21:10 +00:00
f27cc40755 Build ts3j from source as part of the client build
The client resolved ts3j 1.0.3 from ~/.m2, so a machine that pulled new
ts3j sources without re-running mvn install silently shipped the old
library - here one without the packet generation fix, which fails
connects to busy servers with "mac check in EAX failed".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:43:56 +00:00
5fc2405d4a Update ts3j: resend like the official client; clean up failed connects
- ts3j: native resend timing, INIT1 resends, one clientinitiv per handshake,
  non-blocking disconnect.
- Ignore errors from a connect attempt that has been superseded or cancelled.
- Show the whole cause chain in network error messages.
- -Dts3j.debug=true enables ts3j's protocol logging.
- Tests run with a throwaway user.home instead of the real profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 04:58:21 +00:00
c743efc3b6 Keep the window responsive while the channel tree loads
Connecting to a server with ~560 channels froze the UI for over two
seconds: every model event queued its own full tree rebuild, and each
rebuild expanded row by row with Swing's variable-height layout cache,
which measures every row on each expansion.

Coalesce model-change bursts into one rebuild and switch the tree to the
large-model layout (it already has a fixed row height), which only
measures rows on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:21:52 +00:00
3187608579 Update ts3j: survive packet loss during the handshake; allow 30 s to connect
Picks up the ts3j fixes for "mac check in EAX failed" disconnects on lossy
links (retransmitted handshake packets now decrypt) and for the handshake
stalling when clientek's ACK is lost, plus tolerating an empty
client_servergroups.

The connect timeout also covers downloading the channel list, which on a
big server over mobile data can take longer than the previous 10 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:21:52 +00:00
9d996f48a2 Scale the interface fractionally on Linux
Java2D on X11 (and so under XWayland) truncates its scale to a whole
number, drawing 150 % at 100 %. On Linux, pin Java2D to 1 and hand the
full factor to FlatLaf, taken from XSETTINGS Xft/DPI, KWin's Xwayland
scale or GDK_SCALE, or chosen in Options → Design.

The client's own sizes now go through UIScale: borders, gaps, dialog and
row sizes, icons (rendered at the scaled size, so they stay crisp),
custom-painted components and font sizes in the HTML views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:33:27 +00:00
1fddae3f0b Always rebuild the swing jar before shading it
Without forceCreation, maven-jar-plugin kept the previous shaded
ts3-client.jar when the module's classes were unchanged, and the shade then
used it as input: the stale ts3j classes inside it won over the freshly
installed ts3j, so ts3j fixes never reached the built client without a clean.

Also updates ts3j for the empty server-group parsing fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:30:48 +00:00
33411fcd13 Update ts3j: fix EAX MAC failures on busy servers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:09:09 +00:00
2a73900dcc Show client avatars and let the user set their own
Avatars are fetched from the server's file repository as /avatar_<id>,
verified against client_flag_avatar and cached on disk; animated GIFs play in
the info panel. Self > Set avatar... uploads or removes our own picture within
the server's size limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:03:00 +00:00
dbb425403a Import bookmarks and identities from the TeamSpeak 3 client
The official client keeps what a myTeamSpeak account synchronises as protobuf
Item_Data blobs in the ProtobufItems table of settings.db. Decode them, adopt
them on first run when we have no identity yet, and offer a re-import from
Manage Bookmarks and Identities. The settings.db reader moves from contacts to
a new teamspeak package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:02:52 +00:00
bab89f9b50 Add the chat log store missing from earlier commits
TeamspeakConnection and ChatPanel already use com.ts3client.chatlog, but the
package itself was never committed, so the tree did not build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:02:40 +00:00
f409f500fd Add the contact list (friends, blocked, neutral)
Mirrors the TeamSpeak 3 client's Contacts: clients can be filed as friend,
blocked or neutral from their context menu or by dragging them into the
Contacts window, given a custom and phonetic nickname, shown under either
name or both, and per contact muted automatically, ignored in server/channel
or private chat and pokes, have their away message hidden and whispers
allowed or denied. Friends are green and blocked clients red in the tree
(optional), new contacts start from per-type defaults ("Set Defaults"), and
the global whisper policy lives in Options -> Contacts. "Change Volume..."
adds a per-client dB gain that a contact entry remembers.

Contacts are stored in ~/.ts3jclient/contacts.txt using the exact record
format the official client keeps in the Contacts table of its settings.db
(field names and codes recovered from the binary), so entries move between
the two verbatim. A small read-only pure-Java SQLite reader imports the
official client's list on first run and on demand, without a driver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 07:50:58 +00:00
22e0304f25 Add the Tools > Ban List window
Reimplements the official client's ban list: a searchable table of the
server's bans with Add / Remove / Edit, an own-ban filter and highlight,
choosable columns, and the Add/Edit dialog whose IP and name patterns
can be read as IPv4/IPv6 wildcards, fixed strings or regular expressions
(translated to the regexes banadd takes exactly as the official client
does). Editing is add-then-delete, since the protocol has no banedit.

The ban list is read from the notifybanlist burst that answers banlist,
like channelpermlist. The menu item is only shown where the server grants
b_client_ban_list.

Also fixes ServerModel numbering permissions from 0 instead of 1, which
had every selfPermissionValue() lookup reading the neighbouring
permission — including the group-assignment menus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:00:18 +00:00
636090d075 Start overlapping notification sounds without delay
The sound mixer rendered as fast as the playback line accepted, running a
full device buffer ahead of real time, so a sound fired while another
played could only join the mix behind all of that queued audio, and each
further one landed later still. Pace the mixer against the clock so it
never commits more than a few frames ahead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:54:02 +00:00
ad62f5fc92 Update connection and chat handling 2026-09-06 19:14:42 +00:00
62d5af7cf9 Smooth incoming voice under jitter 2026-09-06 13:43:11 +00:00
a01c598fe6 Remember how tall the user made the info panel
Kept as a share of the tree/info column, so it survives new tabs, a resized
window and the next session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:11:29 +00:00
02533b543d Sit the info panel's link in the viewport's corner
It overlapped the scroll bar; the viewport is what the scroll bars have
already been taken out of. The layered pane also has to report a size now,
or the split pane above it has nothing to size the panel from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:11:29 +00:00
5a00015824 Float the info panel's "Show in chat tab" link in its corner
As a real label over the text, it stays reachable however long a description
runs, instead of trailing off the end of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:46:42 +00:00
ffe98be649 Trim the info panel's rows
A channel's permanent/temporary type is not worth a row, and a client's
platform reads better on the version line: Version: 3.6.2 … on Linux.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:46:36 +00:00
65f3777673 Let a chat tab grow to fit its name
Its header was pinned to the width it was built with, clipping longer names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:27:17 +00:00
9101542f0a Switch chat tabs by clicking them, and tighten their padding
A custom tab component swallows the press, so the tabbed pane never selected
the tab itself; the reorder listener already on every tab part now does it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:04:26 +00:00
a4a8b39410 Paint the Away button as flat as its neighbours
Inside its panel it was drawn as a filled button, which read as half-active.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:58:08 +00:00
f480262c39 Drop the chat's Send button
Enter sends; the button only ate width.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:57:40 +00:00
8e3e9401d2 Name the server and channel chat tabs after where we are
Both carry an icon now, as the private chats do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:57:38 +00:00
966f3fe9b1 Log our own channel switches in the server tab
Every other client's move was logged; ours went by silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:57:23 +00:00
b85d1e5076 Connect a left-clicked bookmark on the current tab
Middle-click still opens the server in a new tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:55:29 +00:00
50b71eb12e Leave the microphone alone when muting the speakers
Deafening silenced capture too, so coming back needed two clicks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 06:53:59 +00:00
98719d717e Darken the dark theme, and hover a tree row like a menu
The client's own look-and-feel defaults now live beside LookAndFeelManager as
FlatLaf properties files, which FlatLaf loads on top of each theme's own,
instead of a handful of UIManager.put calls.

The dark theme is built on three surfaces keyed to the toolbar's #282828: the
window chrome, the tab strips and menu bar one step below it at #232323, and
the content the client shows — channel tree, chat log, text fields, lists — at
#1e1e1e. Selections are #364b6f, and channel names in the tree are white; the
chat lines about server events keep the blue they had, which channel names no
longer carry. Everything FlatLaf derives (borders, scroll bars, hover states)
follows from those.

A read-only editor pane is a chat log or an information view here, not a
disabled input, so it keeps the content background rather than FlatLaf's
inactive one; and since neither a text view nor a tree fills its viewport, the
space below the last line takes their background instead of the window's.

The row under the pointer is now washed in the same colour a hovered menu is,
rather than outlined. FlatLaf states that colour as a shift of the surface
beneath it, so it is resolved against the window background exactly as the
menu bar resolves it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:07:19 +00:00
35f10ed14c Paint the client with FlatLaf, in a light and a dark theme
The look-and-feel is FlatLaf now, chosen light or dark on the new Design
tab and remembered in the settings. Theme keeps only the colours TeamSpeak
gives meaning to (talking, away, muted, channel and server names) and takes
its surfaces and fonts from the look-and-feel, so a switch recolours the
whole window, chat logs included: the HTML panes share one themed stylesheet
and re-parse their content when the theme changes.

Also fixes the channel tree not scrolling: the tree listened for the mouse
wheel to re-check its hover row, and AWT only forwards a wheel event to the
enclosing scroll pane when the component under the pointer has no wheel
listener of its own. The hover row now follows the viewport instead, which
also covers scrollbar drags and keyboard scrolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:06:55 +00:00
d952ffd856 Put each client limit's count beside its "Limited" choice
The counts sat on a line of their own below the choices they belong to,
which also made the Advanced tab the tallest of the four and so decided
how much of the dialog the tab strip took. Each box now keeps its
natural height, and the free space collects below them.

Advanced went from 250px to 220px of preferred height, leaving the tab
strip about a third of the dialog rather than half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:11:40 +00:00
acfe25db15 Show our own name in bold, in the row and in its editor
The renderer asks the panel which id is ours rather than reading
ClientEntry.self: our own row usually arrives among the enter-view events
of the connect handshake, before the connection knows its own client id,
so that flag was left false. It is now stamped once the id is known,
which also fixes the "(you)" marker in the info panel.

The inline editor matches, and sits 2px further left so its text lines up
with the label it replaces rather than with its own border.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:59:55 +00:00
08e6c51e90 Leave a talking client's name alone, and rename from the whole row
Bolding the name of whoever is speaking re-measures the row while the
label keeps its old width, so a nickname spent every utterance clipped to
an ellipsis. Talking already shows in the client's icon, which is where
TeamSpeak shows it too, so the name keeps its normal weight and colour.

Renaming now starts from the tree's own double-click handling, which
works a row by its whole line, instead of Swing's, which only sees clicks
that land on the label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:50:03 +00:00
acd438dd14 Don't send a rename that changes nothing
Closing the inline editor without touching the name asked the server to
rename us to the name we already hold, which it refuses with "nickname
is already in use".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:43:00 +00:00
6e65797f25 Rename yourself by double-clicking your own tree row
The row turns into a text field holding the current nickname, selected so
typing replaces it; Enter commits, Escape leaves the name alone. Only our
own row edits — every other double click keeps its meaning, and ours no
longer opens a private chat with ourselves.

The editor keeps the row's own icon (the inherited one knows only the
renderer's default leaf icon) and selects the text on a short delay,
after the release that opened it has been turned into a caret placement.
The tree nodes only mirror the connection's model, so the typed name is
sent to the server rather than written into the node; the row follows
when the rename is reported back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:38:49 +00:00
fa99cadc44 Fill the selected tree row across its whole width
The cell renderer paints the selection only behind the label, which now
looks short next to the full-width hover outline and the full-line hit
area. The tree fills the selected rows itself, in the renderer's own
selection colour so the two meet seamlessly, which means it can no
longer clear its background as an opaque component would.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:16:06 +00:00
9ba0982f25 Work tree rows by their whole line, and outline the hovered one
Swing hit-tests a tree row only as far as its label, so a click past the
end of the name did nothing. Rows are now found by their vertical band
alone, which covers selection, the context menus, double-click join and
middle-click info; the expand handle to the left of a label still works
the row without selecting it.

The hovered row is outlined across that same full width, so what a click
would act on is visible before making it. Spacers, which nothing can be
done with, are skipped, and the outline gives way to the drag-and-drop
indicator while a drag is in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:56:17 +00:00
275de77c8b Bold the channel references in the log, as the client ones are
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:24:05 +00:00
189620df7f Link the clients and channels named in server log lines
Log lines now name clients and channels with TeamSpeak's own client:// and
channel:// links, the same ones a dragged-in reference produces, so the
chat log's existing handling turns them into the context menus.

The log is rendered with a links-only pass rather than the full BBCode
renderer: its lines are assembled by the client itself, so nothing else in
them should be interpreted — a channel called "[b]VIP" stays literal.
Channels we no longer have (a deleted one) are still named in plain text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:11:14 +00:00
cb05b097a6 Add "Change Description" to the client menu
Offered for our own client as well as others, with TS3's edit icon. The
description is read back from the server before prompting, since the
model only carries one for clients whose info has been looked at.

Note that client_description is not a clientupdate property even for
ourselves — the server only takes it through clientedit — and that our
own change is not echoed back to us, so the local entry is updated here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:04:35 +00:00
bcc034e975 Refresh a channel's cached description when it changes
The description is fetched on demand and cached, but nothing invalidated
it: notifychanneledited never carries the text, and the separate
notifychanneldescriptionchanged was ignored. Handle that event, and
refresh locally after our own edit, since the server does not notify the
invoker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:38:53 +00:00
f68c1e297c Give the channel dialog's spare height to the description
The tabs were the growing half, which left them padding empty space
below their content while the description stayed five rows tall. They
now take their natural height at the bottom of the dialog and every
spare pixel goes to the description instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:12:44 +00:00
52bd180b56 Collect directory listings from the events that carry them
ftgetfilelist is answered with a notifyfilelist event per entry and a
closing notifyfilelistfinished, not with rows of the command's own reply
— and ts3j dispatches anything named notify* as an event, so every
listing came back empty. The entries are now collected for the request
in flight, which is what the icon chooser and the file browser read.

The icons themselves live in the repository's "icons" directory on newer
servers rather than loose in its root, so both places are read. They are
still addressed as /icon_<id> for download, upload and delete, which is
how the server resolves them regardless of where it keeps them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:03:03 +00:00
f2885d33ad Add the Create Channel dialog
The official client creates and edits channels with one dialog, so
ChannelEditDialog becomes ChannelDialog with two entry points. Creating
starts from caller-chosen defaults instead of a channelinfo and sends a
channelcreate carrying only what deviates from a plain new channel —
TeamSpeak checks a create permission per property that is present.

The server row now has a context menu with "Create Channel" and "Create
Spacer" (pre-filled with the lowest free [spacerN], since channel names
must be unique), and a channel's menu offers "Create Channel" and
"Create Sub-Channel", both starting out temporary.

Verified against the test server: a temporary channel is created, and a
permanent one a guest may not create reports the server's refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:31:07 +00:00
d8a56566d4 Add the Edit Channel dialog
Recovers the official client's CreateChannelDialog layout so the widget
set, labels and tab order match: name/icon/password/topic/description
above Standard, Audio, Permissions and Advanced tabs.

Core gains ChannelSettings (diffed against the original so channeledit
only carries changed properties) and ChannelAdmin (channelinfo/edit,
channel permissions, the server icon store). TeamspeakConnection now
owns the event executor so it can drain queued notify events before
reading a command's result, which channelpermlist needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:11:49 +00:00
b29f518d59 Hide/gray client-menu items that can't do anything useful right now
"Find Client in Channel Tree" only makes sense when the menu was
opened somewhere other than the tree itself (chat log links, etc.),
so it's hidden when opened from the tree, and disabled elsewhere if
the client's channel isn't currently subscribed (so the tree
wouldn't actually show them). Adds "Join Channel of Client" above
"Move Client to own Channel", disabled under the same visibility
condition plus a full channel. Both use the CHANNEL_SWITCH icon.

Note: TS3 doesn't expose a channel's join-power threshold as a plain
property (only group membership powers work that way); it's a
permission resolved through channel/channel-group/server-group
inheritance that a regular client can't query, so join-power itself
isn't part of this check.
2026-08-18 15:25:41 +00:00
acf0221837 Add Set Server/Channel Group context menus and dialog
Lets moderators assign or revoke server and channel groups for a
client directly from its context menu, with per-group checkboxes
gated by the client's actual TS3 permissions (i_group_needed_member_
add/remove_power vs. each group's n_member_addp/removep, including
TS3's -1 "unlimited" sentinel). A "Server Groups Dialog..." entry
shows every server group, graying out ones the client can't touch.

Group membership changes now update ClientEntry locally instead of
only logging them, so the checkboxes and dialog reflect a change
immediately instead of needing a reconnect. The server's default
channel group (e.g. "Guest") is hidden from the channel-group menu
since it's not something you assign directly.
2026-08-18 15:07:38 +00:00
e5b607fc29 Split ServerTab into focused collaborator classes
Extract mic/speaker/away/commander state handling into
ServerTabSelfState, ServerTreePanel.Actions delegation (context
menus, drag-drop, link clicks) into ServerTabTreeActions, and
ConnectionListener event marshaling onto the EDT into
ServerTabConnectionEvents. ServerTab keeps its full public API
(636 -> 377 lines); no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:37:58 +00:00
d722377508 Split SettingsDialog into focused tab panels
Extract the Playback/Capture and Voice Activation tabs (device pickers,
gain/volume sliders, noise reduction, VAD/PTT tuning, microphone test)
into DevicesPanel and VoiceActivationPanel, alongside the existing
NotificationsPanel/HotkeysPanel/IconPackPanel/ClientVersionPanel. A new
FormPanel base holds the shared GridBagLayout scaffolding. SettingsDialog
is now a thin coordinator (753 -> 187 lines); its public API and all
settings read/write behavior are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:37:51 +00:00
b694845381 Split TeamspeakConnection into focused collaborator classes
Extract notify-event handling and the native-style server log/sound
announcements into ConnectionEventHandler, and getconnectioninfo
request/response + stats-snapshot building into ConnectionStatsCollector.
TeamspeakConnection remains the public facade (1828 -> 1037 lines); its
external API is unchanged. Pure refactor, no protocol/timing changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:37:43 +00:00
cf5ba94092 Add Master Volume slider, status bar toggle, and toolbar customization menu
The toolbar now has a right-click menu to hide/show the status bar and
a short Master Volume slider (drag or scroll) that multiplies both
voice and notification volume via new Settings.effectiveOutputVolume()/
effectiveSoundVolume() helpers.

Also splits MainFrame's toolbar, menu bar and status bar construction
into their own MainToolbar/MainMenuBar/StatusBar classes, each talking
back to MainFrame only through a Listener interface.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:37:36 +00:00
e5fb87a796 Split ServerTreePanel into focused collaborator classes
Extract cell rendering, right-aligned group-icon-strip painting, the
drop-indicator JTree subclass, and drag-and-drop handling out of
ServerTreePanel into ServerTreeCellRenderer, DropIndicatorTree, and
ServerTreeDragAndDrop, leaving ServerTreePanel as the coordinator
(706 -> 284 lines). Pure refactor, no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:35:42 +00:00
de02f88f1c Add browser-style drag-to-reorder for chat and server tabs
Dragging a tab shows animated snapshots on the glass pane and swaps
with whichever neighbour it reaches the midpoint of; the real tab
model is only touched once, on release, so an in-flight mouse grab
never gets pulled out from under a rebuilt tab component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 12:49:26 +00:00
71084ea309 Log server activity, scrollable chat tabs, and move info panel to a chat tab
Native TS3 logs client joins/leaves/moves, group changes and channel edits
to the server tab; minor local-only notices (mic mute, tray) no longer
spam the log. Chat tabs scroll by mouse wheel like server tabs. The info
panel can now be moved into a persistent, generically-labelled chat tab
that tracks tree selection and collapses the panel's space entirely; a
tree rebuild no longer briefly fires a null selection that reset this.
Added "Find Client in Channel Tree" to the client context menu, and tree
selection now survives model rebuilds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:47:12 +00:00
047c89404c Prefer RECORD over XInput2 on X11: raw button releases can go missing
On at least some driver stacks, XI_RawButtonRelease is never delivered
to a client that hasn't grabbed the pointer, even though the matching
XI_RawButtonPress arrives fine — a bound mouse button then looks stuck
down forever, both when recording a hotkey and when using one. Taking
an active XIGrabDevice grab does fix delivery, but even with
owner_events set it blocks clicks from reaching every other window, so
it's not usable. XRecordInputHook taps the same core events xev sees
and isn't affected, so it now goes first on X11; XInput2 stays as the
fallback for servers where RECORD is disabled or missing, and gets a
correctness cleanup (real per-device event selection instead of the
XIAllMasterDevices pseudo-device) along the way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 10:34:19 +00:00
75ae6b89b2 Report mute/hardware status correctly, add Local Mic Mute, fix stale talking indicator
- Mute/deafen status now goes out through clientupdate instead of
  clientedit, which silently rejected client_input_muted/output_muted
  since they are runtime status, not editable client properties.
- Publish client_input_hardware so other clients see "Microphone
  Disabled" instead of silence while another tab holds the capture
  device; track input/output hardware flags for other clients too,
  and show a distinct grey "disabled" icon instead of reusing the red
  "muted" one for both the tree and the info panel.
- Implement TS3's Enable/Disable/Toggle Local Mic Mute hotkeys: they
  silence capture like a real mute, but never touch the published
  mute status or play a sound.
- A speaker's "talking" indicator only ever cleared when its
  zero-length end-of-burst voice packet arrived; if that one UDP
  packet was lost, the indicator stuck until their next burst. Add a
  watchdog that clears it after 200ms of silence from that speaker
  regardless.
2026-08-17 08:43:22 +00:00
e228dd4ad0 Replace RMS voice detection with TeamSpeak's own RNN VAD
Ports WebRTC's rnn_vad (as TS3 embeds it) to Java: LPC, pitch
estimation, spectral features and the RNN itself, feeding a
speech-probability detector that replaces the old SpeechDetector.
Also switches the volume-gate threshold from raw dBFS to
InputLevel's scale, matching TS3's own slider and range, with a
migration for settings saved under the old key.
2026-08-17 07:51:51 +00:00
752676e863 Capture global hotkeys on Windows through raw input
A message-only window registers the keyboard and mouse with
RIDEV_INPUTSINK, so every key and button arrives as WM_INPUT whether or
not the client is in front. Raw input only observes, where a
WH_KEYBOARD_LL hook sits in the path of the system input queue and can
swallow a keystroke; it also reports the side buttons and both edges of
every key, which push-to-talk needs.

Keyboard codes stay in each platform's own numbering — X keycodes on
X11, set-1 scan codes with the E0/E1 escape folded into the high byte on
Windows — since that is what the input API reports and the key-naming
call expects. Bindings are per-machine either way, as TeamSpeak's own
per-OS keydefs are. Mouse buttons are unified on the X numbering, so
"Mouse 4" means the same thing on both.

The RAWINPUT decoding lives in its own class so it can be tested off
Windows; the window and its pump have not been run on Windows yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:40:50 +00:00
edc8a0ca3a Capture hotkeys through XInput2, as TeamSpeak does
The bundled TS3 Linux binary dlopens libX11 and libXi and drives
XIQueryVersion/XISelectEvents/XGetEventData — XInput2 raw events, with no
trace of the RECORD extension anywhere in its tree. That is the better
choice for us too: XInput2 is core input, present on any remotely modern
server, where RECORD is a debugging extension that is sometimes disabled
or left out of the build. Both are passive, so the keystroke still
reaches the focused window either way.

XRecordInputHook stays as the fallback behind it. Raw events also explain
why TS3's hotkeys work under KWin's Wayland session despite it using no
Wayland API at all: it runs on Xwayland, and KWin's legacy X11 app
support forwards the keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:33:32 +00:00
7e4b9671fe Global hotkeys, in TeamSpeak's own shape
Bindings are captured system-wide rather than only while the window has
focus: X11's RECORD extension where the X server sees every key, and a
/dev/input reader as the Wayland fallback. Any key can act as a modifier,
mouse buttons included, as TS3 allows.

The action catalogue, its three categories and the "advanced actions"
split are reverse-engineered from the original client; actions this
client cannot perform are listed but greyed out. Push-to-talk becomes one
of these hotkeys, so the old focus-bound pushToTalkKey setting is gone and
the Voice Activation button edits that binding instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:08:28 +00:00
3e2c06248f Make the tray menu close, and its window come forward
Swing never grabs the pointer for a popup, so a click on the desktop or
another application is not seen and the menu stays up. The little window
the menu hangs off is focusable now and closes it when the focus goes
elsewhere, Escape closes it, and clicking the icon again toggles it.

Raising the window was ignored for the same reason it usually is: a
window manager refuses a raise from an application that does not have the
focus. The icon asks for _NET_ACTIVE_WINDOW instead — the request meant
for pagers and trays acting for the user — and falls back to Swing's
always-on-top shuffle when it cannot find the window. Windows are matched
by _NET_WM_PID and _NET_WM_NAME, since WM_NAME cannot carry the em dash
in our title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:58:24 +00:00
32e5ffb576 Show the local client's state in the system tray
The icon carries the badge the tree draws next to our own nickname on the
active server — idle, talking, away, commander, microphone or speakers
muted — and clicking it brings the window back to the front. Right click
offers "Show TS3J" and "Quit".

AWT's tray icon cannot be transparent on X11: the toolkit embeds a window
of the screen's default, opaque visual and fills it with a background
colour before drawing the image, so every icon sits in a box. Panels that
can show transparent icons advertise an ARGB visual instead, so the icon
is docked by hand over the system tray protocol, in that visual, with the
image put on the window premultiplied. AWT's own icon stays as the
fallback for everything else.

Two details a panel will not forgive: an icon that publishes no size
hints is allocated a one-pixel sliver, and docking is a request, so an
icon that is never adopted has to hand over to the fallback rather than
sit invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:45:32 +00:00
ef95b6305d Report a chosen client version and operating system
Other clients see the version and platform from clientinit in the info
panel, and until now that was whatever ts3j sends. The new Client Version
tab picks one instead.

Servers refuse a version they do not know with "client is modified", so
free text is not enough: the list holds genuine version/platform/signature
triples from released clients, newest first per platform. The fields stay
editable for a version whose signature is known from elsewhere, and a
refused connection now says where to change it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:06:09 +00:00
598 changed files with 50421 additions and 4664 deletions

20
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,20 @@
name: test
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 25
cache: maven
# Builds ts3j from the submodule along with the client; no test needs a display or libopus.
- run: mvn -B -f ts3-client/pom.xml verify

11
.gitignore vendored
View File

@@ -4,11 +4,14 @@ build/
out/ out/
*.class *.class
# The bundled proprietary TeamSpeak 3 client (reference binary, not our source) # Bundled proprietary TeamSpeak clients (reference binaries, not our source)
/TeamSpeak3-Client-linux_amd64/ /TeamSpeak3-Client-linux_amd64/
/ts6/
/re-android/
# Packages # Packages
*.jar *.jar
!android/gradle/wrapper/gradle-wrapper.jar
*.war *.war
*.zip *.zip
*.tar.gz *.tar.gz
@@ -34,3 +37,9 @@ release.properties
# OS # OS
.DS_Store .DS_Store
# Android / Gradle
android/.gradle/
android/.kotlin/
android/**/.cxx/
android/local.properties

View File

@@ -0,0 +1,122 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
/**
* Puts the sound packs of an installed TeamSpeak 3 client into the app's assets, as the
* desktop client borrows them from the installation, with the sounds encoded as Opus
* (a fifteenth of the WAVs). They are TeamSpeak's and not in the repository; a build
* without the client ships no packs.
*/
abstract class BundleSoundPacks : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val packs: DirectoryProperty
/** The desktop code whose libopus does the encoding. */
@get:Classpath
abstract val encoder: ConfigurableFileCollection
@get:OutputDirectory
abstract val assets: DirectoryProperty
@get:Inject
abstract val exec: ExecOperations
@TaskAction
fun encode() {
val out = assets.get().asFile
out.deleteRecursively()
val source = packs.get().asFile
if (!source.isDirectory) {
logger.warn("No TeamSpeak 3 sound packs at $source; the app ships without sounds")
return
}
exec.javaexec {
classpath = encoder
mainClass.set("com.ts3client.audio.desktop.EncodeSoundPacks")
jvmArgs("--enable-native-access=ALL-UNNAMED")
args(source.path, File(out, "sound").path)
}
}
}
val soundPackEncoder: Configuration by configurations.creating {
isCanBeConsumed = false
attributes {
attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.JAR))
}
}
val ts3ClientDir: String = providers.gradleProperty("ts3.client.dir")
.orElse(providers.environmentVariable("TS3_CLIENT_DIR"))
.getOrElse(rootDir.resolve("../TeamSpeak3-Client-linux_amd64").path)
val bundleSoundPacks = tasks.register<BundleSoundPacks>("bundleSoundPacks") {
packs.set(File(ts3ClientDir, "sound"))
encoder.from(soundPackEncoder)
assets.set(layout.buildDirectory.dir("generated/soundPacks"))
}
androidComponents {
onVariants { variant ->
variant.sources.assets?.addGeneratedSourceDirectory(bundleSoundPacks, BundleSoundPacks::assets)
}
}
android {
namespace = "com.ts3client.android"
compileSdk = 37
ndkVersion = "30.0.16248370"
defaultConfig {
applicationId = "com.ts3client.android"
minSdk = 33
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources {
// BouncyCastle ships Java 9+ module metadata Android has no use for.
excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
}
}
}
dependencies {
implementation(project(":audio"))
soundPackEncoder(project(":desktop"))
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.service)
implementation(libs.androidsvg)
}

10
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,10 @@
# ts3j looks up SRV records through JNDI only where it exists; Android lacks it.
-dontwarn javax.naming.**
# Reached from native code by name.
-keepclasseswithmembernames class com.ts3client.android.audio.** {
native <methods>;
}
# ts3j builds protocol handlers and packet bodies reflectively.
-keep class com.github.manevolent.ts3j.** { *; }

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- Speech sound packs talk through the phone's text-to-speech engine. -->
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
<application
android:name=".Ts3Application"
android:label="@string/app_name"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".ConnectionService"
android:exported="false"
android:foregroundServiceType="mediaPlayback|microphone" />
<receiver
android:name=".ReplyReceiver"
android:exported="false" />
</application>
</manifest>

View File

@@ -0,0 +1,30 @@
package com.ts3client.android
import android.content.Context
import com.ts3client.android.audio.AudioRoute
/** Where the Android-only audio choices are kept; the rest live in the shared settings. */
class AudioRoutePrefs(context: Context) {
private val prefs = context.getSharedPreferences("audio", Context.MODE_PRIVATE)
fun load(): AudioRoute = AudioRoute(
enumOr(prefs.getString(INPUT_PRESET, null), AudioRoute.DEFAULT.inputPreset()),
enumOr(prefs.getString(USAGE, null), AudioRoute.DEFAULT.usage()),
)
fun save(route: AudioRoute) {
prefs.edit()
.putString(INPUT_PRESET, route.inputPreset().name)
.putString(USAGE, route.usage().name)
.apply()
}
private inline fun <reified E : Enum<E>> enumOr(name: String?, default: E): E =
enumValues<E>().firstOrNull { it.name == name } ?: default
private companion object {
const val INPUT_PRESET = "inputPreset"
const val USAGE = "usage"
}
}

View File

@@ -0,0 +1,88 @@
package com.ts3client.android
import androidx.compose.runtime.Immutable
import com.ts3client.text.HtmlText
import com.ts3client.text.TextQuery
/** Which conversation a chat line belongs to. */
sealed interface ChatKey {
data object Server : ChatKey
data object Channel : ChatKey
data class Private(val clientId: Int) : ChatKey
}
/** One entry of a chat, as HTML in the form [com.ts3client.text.ChatHtml] writes. */
@Immutable
data class ChatLine(val id: Long, val html: String) {
/** The text to find in. */
val plain: String by lazy { HtmlText.plain(html) }
}
@Immutable
data class Conversation(
val key: ChatKey,
val title: String,
/** The peer's unique id in a private chat, once known. */
val uniqueId: String = "",
val lines: List<ChatLine> = emptyList(),
val unread: Boolean = false,
)
/** Every conversation of the session: the server's, the channel's, then the private ones. */
@Immutable
data class Chats(
val server: Conversation = Conversation(ChatKey.Server, "Server"),
val channel: Conversation = Conversation(ChatKey.Channel, "Channel"),
val private: List<Conversation> = emptyList(),
) {
val all: List<Conversation> get() = listOf(server, channel) + private
val anyUnread: Boolean get() = all.any { it.unread }
operator fun get(key: ChatKey): Conversation? = when (key) {
ChatKey.Server -> server
ChatKey.Channel -> channel
is ChatKey.Private -> private.firstOrNull { it.key == key }
}
/** Applies [change] to a conversation, opening a private one titled [title] if needed. */
fun update(key: ChatKey, title: String = "", change: (Conversation) -> Conversation): Chats = when (key) {
ChatKey.Server -> copy(server = change(server))
ChatKey.Channel -> copy(channel = change(channel))
is ChatKey.Private -> {
val existing = private.indexOfFirst { it.key == key }
if (existing < 0) {
copy(private = private + change(Conversation(key, title)))
} else {
copy(private = private.toMutableList().also { it[existing] = change(it[existing]) })
}
}
}
fun close(key: ChatKey.Private): Chats = copy(private = private.filter { it.key != key })
companion object {
/** As many lines as a log history loads; older ones leave the conversation. */
const val MAX_LINES = 1000
}
}
fun Conversation.append(line: ChatLine, read: Boolean): Conversation =
copy(lines = (lines + line).takeLast(Chats.MAX_LINES), unread = unread || !read)
/** The [nth] match of the find bar's query in the line [line]. */
data class ChatMatch(val line: Long, val nth: Int)
/** Every match of [query] in [lines], oldest first; past [limit], only the newest. */
fun findIn(lines: List<ChatLine>, query: TextQuery, limit: Int): List<ChatMatch> {
if (query.isEmpty) return emptyList()
val out = ArrayList<ChatMatch>()
for (line in lines) {
val m = query.matcher(line.plain) ?: return emptyList()
var n = 0
while (m.find()) {
if (m.end() > m.start()) out += ChatMatch(line.id, n++)
}
}
return if (out.size > limit) out.subList(out.size - limit, out.size) else out
}

View File

@@ -0,0 +1,231 @@
package com.ts3client.android
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.graphics.Color
import android.graphics.drawable.Icon
import android.net.wifi.WifiManager
import android.os.PowerManager
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import com.ts3client.config.Settings
import com.ts3client.net.ClientStatus
import com.ts3client.session.SelfState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/**
* Keeps the process in the foreground while connected, so the connection and its audio
* survive the app leaving the screen, and offers the essentials in its notification.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ConnectionService : LifecycleService() {
private lateinit var sessions: SessionController
private lateinit var wifiLock: WifiManager.WifiLock
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var overlay: PttOverlay
private var foregroundTypes = 0
private var shown: ServerController? = null
override fun onCreate() {
super.onCreate()
sessions = (application as Ts3Application).sessions
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, getString(R.string.connection_channel), NotificationManager.IMPORTANCE_LOW)
)
// Voice goes out every 20 ms; Wi-Fi power saving and a sleeping CPU would stretch that.
wifiLock = getSystemService(WifiManager::class.java)
.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "ts3j:voice")
.apply { setReferenceCounted(false); acquire() }
wakeLock = getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ts3j:connection")
.apply { setReferenceCounted(false); acquire() }
show(sessions.current.value?.state?.value ?: SessionState(), capturing = false)
lifecycleScope.launch {
// The server count shows in the notification too.
combine(shownServer(), sessions.micOwner, sessions.servers) { shown, owner, _ -> shown to (owner != null) }
.conflate()
.collect { (shown, capturing) ->
if (shown != null) {
this@ConnectionService.shown = shown.first
show(shown.second, capturing)
// Talking flips the icon often, and Android drops updates that come too fast.
delay(MIN_UPDATE_MS)
}
}
}
lifecycleScope.launch {
sessions.current.flatMapLatest { it?.state ?: flowOf(null) }.collectLatest { state ->
if (state == null) {
// A reconnect leaves the server briefly; only stop once it stays gone.
delay(STOP_DELAY_MS)
stopSelf()
}
}
}
lifecycleScope.launch {
sessions.icons.active.drop(1).collect { shown?.let { show(it.state.value, sessions.micOwner.value != null) } }
}
overlay = PttOverlay(this, sessions.icons, sessions::setPushToTalk)
lifecycleScope.launch {
val owner = sessions.micOwner.flatMapLatest { server ->
server?.let { combine(it.state, it.tree) { state, tree -> state to tree } } ?: flowOf(null)
}
combine(owner, sessions.onScreen, sessions.pttOverlay) { held, onScreen, enabled ->
val (state, tree) = held ?: return@combine null
val wanted = enabled && !onScreen && state.connected && state.micActive &&
state.inputMode == Settings.InputMode.PUSH_TO_TALK
val status = tree.client(state.selfClientId)?.status
when {
!wanted -> null
state.micMuted -> PttOverlay.Look.MUTED
status == ClientStatus.TALKING || status == ClientStatus.COMMANDER_TALKING -> PttOverlay.Look.TALKING
else -> PttOverlay.Look.IDLE
}
}.distinctUntilChanged().collect { look ->
if (look == null) overlay.hide() else overlay.show(look)
}
}
}
/**
* The server the notification speaks for, as the desktop tray icon does: the one
* holding the microphone, or the visible one when nobody is capturing.
*/
private fun shownServer(): Flow<Pair<ServerController, SessionState>?> =
combine(sessions.current, sessions.micOwner) { current, owner -> current to owner }
.flatMapLatest { (current, owner) ->
combine(current?.state ?: flowOf(null), owner?.state ?: flowOf(null)) { currentState, ownerState ->
when {
owner != null && ownerState?.connected == true -> owner to ownerState
current != null && currentState != null -> current to currentState
else -> null
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val server = shown ?: sessions.current.value ?: return START_NOT_STICKY
val state = server.state.value
when (intent?.action) {
ACTION_TOGGLE_MUTE -> server.setMicMuted(!state.micMuted)
ACTION_TOGGLE_DEAFEN -> server.setDeafened(!state.deafened)
ACTION_DISCONNECT -> server.disconnect()
}
return START_NOT_STICKY
}
/**
* The user swiped the app away: they are done with it, connection included. Leaving
* properly spares the server a ghost that would hold our identity until it times out.
*/
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
lifecycleScope.launch(Dispatchers.IO) {
sessions.leave()
stopSelf()
}
}
override fun onDestroy() {
overlay.hide()
wifiLock.release()
wakeLock.release()
super.onDestroy()
}
/** Posts the notification, re-declaring what the service uses when that changed. */
private fun show(state: SessionState, capturing: Boolean) {
val types = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
if (capturing && hasMicPermission()) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0
val notification = notification(state)
if (types != foregroundTypes) {
startForeground(NOTIFICATION_ID, notification, types)
foregroundTypes = types
} else {
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification)
}
}
private fun hasMicPermission() =
checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
private fun notification(state: SessionState): Notification {
val open = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
)
val others = sessions.servers.value.size - 1
return Notification.Builder(this, CHANNEL)
.setSmallIcon(statusIcon(state.self))
.setLargeIcon(largeIcon(state.self))
.setContentTitle(if (others > 0) "${state.title} +$others" else state.title)
.setContentText(state.status)
.setContentIntent(open)
.setOngoing(true)
.addAction(action(ACTION_TOGGLE_MUTE, if (state.micMuted) "Unmute" else "Mute"))
.addAction(action(ACTION_TOGGLE_DEAFEN, if (state.deafened) "Undeafen" else "Deafen"))
.addAction(action(ACTION_DISCONNECT, "Disconnect"))
.build()
}
/**
* The status bar shows only the shape of an icon, which pack art loses, so it is
* drawn from the Material pack whatever pack is chosen.
*/
private fun statusIcon(self: SelfState): Icon {
val key = self.iconKey() ?: return Icon.createWithResource(this, android.R.drawable.ic_btn_speak_now)
val size = (STATUS_ICON_DP * resources.displayMetrics.density).roundToInt()
val bitmap = sessions.icons.renderMaterial(key, size, Color.WHITE)
?: return Icon.createWithResource(this, android.R.drawable.ic_btn_speak_now)
return Icon.createWithBitmap(bitmap)
}
/** The expanded notification has room for the chosen pack's own art, as the tree draws it. */
private fun largeIcon(self: SelfState): Icon? {
val key = self.iconKey() ?: return null
val size = (LARGE_ICON_DP * resources.displayMetrics.density).roundToInt()
return sessions.icons.render(listOf(key), size, getColor(android.R.color.system_neutral1_500))
?.let(Icon::createWithBitmap)
}
private fun action(action: String, label: String): Notification.Action {
val intent = PendingIntent.getService(
this, action.hashCode(), Intent(this, ConnectionService::class.java).setAction(action),
PendingIntent.FLAG_IMMUTABLE
)
return Notification.Action.Builder(null, label, intent).build()
}
private companion object {
const val CHANNEL = "connection"
const val NOTIFICATION_ID = 1
const val STOP_DELAY_MS = 3_000L
const val MIN_UPDATE_MS = 250L
const val STATUS_ICON_DP = 24
const val LARGE_ICON_DP = 48
const val ACTION_TOGGLE_MUTE = "com.ts3client.android.TOGGLE_MUTE"
const val ACTION_TOGGLE_DEAFEN = "com.ts3client.android.TOGGLE_DEAFEN"
const val ACTION_DISCONNECT = "com.ts3client.android.DISCONNECT"
}
}

View File

@@ -0,0 +1,143 @@
package com.ts3client.android
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.net.Uri
import android.provider.OpenableColumns
import android.util.LruCache
import com.caverock.androidsvg.RenderOptions
import com.caverock.androidsvg.SVG
import com.ts3client.config.AppDirs
import com.ts3client.config.Settings
import com.ts3client.gfx.IconPack
import com.ts3client.gfx.IconPacks
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.io.File
/**
* The icon packs the app draws with: the Material pack it ships, and TeamSpeak packs
* imported into its `gfx` folder, which are the very zips a TeamSpeak 3 install keeps in
* its own. Whatever the chosen pack lacks comes from the Material one.
*/
class IconTheme(private val context: Context, private val settings: Settings) {
private val folder = File(AppDirs.profile(), "gfx")
private val material: IconPack = IconPacks.material()
@Volatile
private var lookupOrder = listOf(material)
private val cache = LruCache<String, Bitmap>(256)
private val _packs = MutableStateFlow(emptyList<IconPack>())
val packs: StateFlow<List<IconPack>> = _packs
private val _active = MutableStateFlow(material)
val active: StateFlow<IconPack> = _active
init {
reload()
}
fun select(pack: IconPack) {
settings.iconPack = pack.id()
settings.save()
reload()
}
/**
* Copies a pack's zip into the app and switches to it.
*
* @return the pack, or `null` when the file is no icon pack
*/
fun import(uri: Uri): IconPack? {
folder.mkdirs()
val target = File(folder, displayName(uri).substringAfterLast('/').let {
if (it.endsWith(".zip", ignoreCase = true)) it else "$it.zip"
})
val staged = File(folder, target.name + ".part")
context.contentResolver.openInputStream(uri)?.use { input ->
staged.outputStream().use { input.copyTo(it) }
} ?: return null
target.delete()
staged.renameTo(target)
val pack = IconPack.load(target)
if (pack == null) {
target.delete()
return null
}
select(pack)
return _active.value
}
/**
* Draws the first of [keys] the packs have.
*
* @param rgb the colour for art that paints in the text colour
*/
fun render(keys: List<String>, size: Int, rgb: Int): Bitmap? = render(lookupOrder, keys, size, rgb)
/** Draws from the Material pack whatever pack is chosen, for chrome that must stay legible on its own colours. */
fun renderMaterial(key: String, size: Int, rgb: Int): Bitmap? = render(listOf(material), listOf(key), size, rgb)
private fun render(order: List<IconPack>, keys: List<String>, size: Int, rgb: Int): Bitmap? {
val cacheKey = "${order.first().id()}/${keys.joinToString(",")}@$size#$rgb"
cache.get(cacheKey)?.let { return it }
for (key in keys) {
for (pack in order) {
val data = pack.icon(key, size) ?: continue
val bitmap = (if (pack.isVector(key, size)) rasterize(IconPack.withCurrentColor(data, rgb), size)
else decode(data, size)) ?: continue
cache.put(cacheKey, bitmap)
return bitmap
}
}
return null
}
private fun reload() {
val packs = IconPacks.findAll(folder, null)
val active = IconPacks.select(packs, settings.iconPack) ?: material
lookupOrder = IconPacks.lookupOrder(active, packs, material)
cache.evictAll()
_packs.value = packs
_active.value = active
}
private fun displayName(uri: Uri): String =
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
if (it.moveToFirst()) it.getString(0) else null
} ?: uri.lastPathSegment ?: "iconpack.zip"
private fun rasterize(svg: ByteArray, size: Int): Bitmap? = try {
val document = SVG.getFromString(String(svg, Charsets.UTF_8))
if (document.documentViewBox == null && document.documentWidth > 0 && document.documentHeight > 0) {
document.setDocumentViewBox(0f, 0f, document.documentWidth, document.documentHeight)
}
// Otherwise the art keeps the size it was authored at inside the viewport.
document.setDocumentWidth("100%")
document.setDocumentHeight("100%")
Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).also {
document.renderToCanvas(Canvas(it), RenderOptions().viewPort(0f, 0f, size.toFloat(), size.toFloat()))
}
} catch (e: Exception) {
null
}
/** Scales a bitmap icon into the square, keeping the aspect ratio of art that is not. */
private fun decode(data: ByteArray, size: Int): Bitmap? {
val source = BitmapFactory.decodeByteArray(data, 0, data.size) ?: return null
if (source.width == size && source.height == size) return source
val factor = minOf(size.toFloat() / source.width, size.toFloat() / source.height)
val w = source.width * factor
val h = source.height * factor
return Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).also {
val left = (size - w) / 2
val top = (size - h) / 2
Canvas(it).drawBitmap(source, null, RectF(left, top, left + w, top + h), Paint(Paint.FILTER_BITMAP_FLAG))
}
}
}

View File

@@ -0,0 +1,170 @@
package com.ts3client.android
import android.content.Context
import android.net.Uri
import com.ts3client.config.Bookmarks
import com.ts3client.config.IdentityEntry
import com.ts3client.config.IdentityStore
import com.ts3client.config.Settings
import com.ts3client.teamspeak.TeamSpeakImporter
import com.ts3client.teamspeak.TeamSpeakSettingsDb
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.IOException
/** What the identity manager shows of one identity. */
data class IdentityInfo(
val id: String,
val name: String,
val uniqueId: String,
val securityLevel: Int,
val isDefault: Boolean,
val nickname: String,
val phoneticNickname: String,
)
/** A search for a better key offset under way. */
data class Improvement(val id: String, val target: Int, val reached: Int)
/**
* The identities, as TeamSpeak's Tools → Identities manages them: create, import, export,
* rename, remove, pick the default and raise the security level. Raising the level runs
* on until it is reached or stopped, whichever page is on screen.
*/
class IdentityManager(
private val context: Context,
private val store: IdentityStore,
private val settings: Settings,
private val bookmarks: Bookmarks,
private val onBookmarksChanged: () -> Unit,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _all = MutableStateFlow(snapshot())
val all: StateFlow<List<IdentityInfo>> = _all
private val _improving = MutableStateFlow<Improvement?>(null)
val improving: StateFlow<Improvement?> = _improving
private var improvement: Job? = null
/** Picks up identities added elsewhere, such as by an import or a first connect. */
fun refresh() {
_all.value = snapshot()
}
private fun snapshot(): List<IdentityInfo> = store.all().map {
IdentityInfo(it.id, it.name, it.uniqueId, it.securityLevel, it.id == settings.defaultIdentityId,
it.nickname, it.phoneticNickname)
}
/** Generates an identity at TeamSpeak's default security level; the first one becomes the default. */
suspend fun generate(name: String): IdentityInfo = added {
store.generate(name, IdentityStore.DEFAULT_SECURITY_LEVEL)
}
/** Imports a TeamSpeak identity file, under the name it carries. */
suspend fun importFile(uri: Uri): IdentityInfo = added {
context.contentResolver.openInputStream(uri)?.use { store.importStream(it, null) }
?: throw IOException("Cannot open the file")
}
/** Imports the identities of a TeamSpeak 3 `settings.db`. */
suspend fun importFromTeamSpeak(uri: Uri): TeamSpeakImporter.Result = withContext(Dispatchers.IO) {
withLocalCopy(context, uri) { TeamSpeakImporter(store, bookmarks, settings).importIdentities(it) }
}.also {
adoptDefault(null)
refresh()
}
private suspend fun added(create: () -> IdentityEntry): IdentityInfo {
val entry = withContext(Dispatchers.IO) { create() }
adoptDefault(entry)
refresh()
return _all.value.first { it.id == entry.id }
}
/** Makes [entry], or else the first identity, the default while there is none. */
private fun adoptDefault(entry: IdentityEntry?) {
if (store.byId(settings.defaultIdentityId) != null) return
val fallback = entry ?: store.all().firstOrNull() ?: return
settings.defaultIdentityId = fallback.id
settings.save()
}
/** Writes the identity out in TeamSpeak's format, for the official client or another device. */
suspend fun export(id: String, uri: Uri) = withContext(Dispatchers.IO) {
val entry = store.byId(id) ?: return@withContext
val out = context.contentResolver.openOutputStream(uri, "wt") ?: throw IOException("Cannot write the file")
store.exportTo(entry, out)
}
fun rename(id: String, name: String) {
val entry = store.byId(id) ?: return
scope.launch(Dispatchers.IO) {
store.rename(entry, name)
refresh()
}
}
fun setNicknames(id: String, nickname: String, phoneticNickname: String) {
val entry = store.byId(id) ?: return
scope.launch(Dispatchers.IO) {
store.setNicknames(entry, nickname, phoneticNickname)
refresh()
}
}
/** The nickname of the identity a connect with [id] uses; empty when it has none. */
fun nicknameOf(id: String): String =
(store.byId(id) ?: store.byId(settings.defaultIdentityId))?.nickname.orEmpty()
fun setDefault(id: String) {
settings.defaultIdentityId = id
settings.save()
refresh()
}
/** Deletes the identity; bookmarks that connected with it fall back to the default. */
fun remove(id: String) {
val entry = store.byId(id) ?: return
scope.launch(Dispatchers.IO) {
// Else the search would write the file back once it ends.
if (_improving.value?.id == id) improvement?.cancelAndJoin()
store.remove(entry, settings, bookmarks)
onBookmarksChanged()
refresh()
}
}
/** Searches for a key offset reaching [target], keeping the best found when stopped. */
fun improve(id: String, target: Int) {
if (improvement?.isActive == true) return
val entry = store.byId(id) ?: return
_improving.value = Improvement(id, target, entry.securityLevel)
improvement = scope.launch {
try {
store.improveSecurity(entry, target) { reached, _ ->
_improving.value = Improvement(id, target, reached)
isActive
}
} catch (e: IOException) {
// The level reached stays in memory for this run.
} finally {
_improving.value = null
refresh()
}
}
}
fun stopImproving() {
improvement?.cancel()
}
}

View File

@@ -0,0 +1,95 @@
package com.ts3client.android
import com.ts3client.audio.AudioBackend
import com.ts3client.audio.InputLevel
import com.ts3client.audio.VoiceInput
import com.ts3client.config.Settings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** The microphone's level on [InputLevel]'s scale, and whether that would be sent. */
data class MicLevel(
val db: Double = InputLevel.SILENCE_DB,
val transmitting: Boolean = false,
/** Whether a microphone is being listened to at all. */
val live: Boolean = false,
)
/**
* The level meter behind the voice-activation settings. While connected it listens to the
* connection's own microphone, so it shows exactly what gets sent; otherwise it runs a test
* microphone of its own for as long as someone watches.
*/
class LevelMonitor(private val settings: Settings, private val audio: AudioBackend) {
private val _level = MutableStateFlow(MicLevel())
val level: StateFlow<MicLevel> = _level
/** The connection's microphone, when that is the one listened to. */
private var borrowed: VoiceInput? = null
private var test: VoiceInput? = null
/**
* Starts or stops listening.
*
* @param source the connection's microphone while it captures, else null
* @param busy whether a connection is under way, which will bring its own microphone
*/
@Synchronized
fun update(watching: Boolean, source: VoiceInput?, busy: Boolean, mayCapture: Boolean) {
when {
!watching -> release()
source != null -> if (borrowed !== source) {
release()
source.setLevelListener(::onLevel)
borrowed = source
}
busy || !mayCapture -> release()
test == null -> startTest()
}
}
/** The own microphone's transmit state, which the connection reports. */
fun talking(transmitting: Boolean) {
if (borrowed != null) _level.value = _level.value.copy(transmitting = transmitting)
}
/** Puts changed voice settings into effect on the test microphone. */
@Synchronized
fun apply() {
test?.let {
it.apply(settings)
it.setMode(testMode())
}
}
private fun startTest() {
release()
val input = audio.createInput(settings)
input.setMode(testMode())
input.setLevelListener(::onLevel)
input.setTalkListener { _level.value = _level.value.copy(transmitting = it) }
try {
input.start()
} catch (e: RuntimeException) {
return // no microphone to be had; the meter stays idle
}
test = input
}
private fun release() {
borrowed?.setLevelListener(null)
borrowed = null
test?.stop()
test = null
_level.value = MicLevel()
}
private fun onLevel(db: Double) {
_level.value = _level.value.copy(db = db, live = true)
}
/** Push-to-talk would never open without the button, and the test is there to tune the gate. */
private fun testMode() =
if (settings.inputMode == Settings.InputMode.PUSH_TO_TALK) Settings.InputMode.VOICE_ACTIVATION else settings.inputMode
}

View File

@@ -0,0 +1,21 @@
package com.ts3client.android
import android.content.Context
import android.net.Uri
import java.io.File
import java.io.IOException
/**
* Hands [read] a temporary copy of the document at [uri], for readers that need a file they
* can seek in, which a content URI is not.
*/
internal fun <T> withLocalCopy(context: Context, uri: Uri, read: (File) -> T): T {
val copy = File.createTempFile("import", null, context.cacheDir)
try {
val input = context.contentResolver.openInputStream(uri) ?: throw IOException("Cannot open the file")
input.use { copy.outputStream().use { out -> it.copyTo(out) } }
return read(copy)
} finally {
copy.delete()
}
}

View File

@@ -0,0 +1,45 @@
package com.ts3client.android
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.ts3client.android.ui.App
import com.ts3client.android.ui.Ts3Theme
class MainActivity : ComponentActivity() {
private val sessions get() = (application as Ts3Application).sessions
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
if (savedInstanceState == null) openRequestedChat(intent)
setContent {
Ts3Theme { App(sessions) }
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
openRequestedChat(intent)
}
override fun onStart() {
super.onStart()
sessions.foreground = true
}
override fun onStop() {
sessions.foreground = false
super.onStop()
}
/** A private message notification opens its conversation. */
private fun openRequestedChat(intent: Intent) {
val serverId = intent.getIntExtra(MessageNotifier.EXTRA_SERVER, -1)
val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1)
if (serverId >= 0 && clientId >= 0) sessions.requestChat(serverId, clientId)
}
}

View File

@@ -0,0 +1,141 @@
package com.ts3client.android
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Person
import android.app.RemoteInput
import android.content.Context
import android.content.Intent
/**
* Tells the user about pokes and private messages while the app is out of sight. The
* notifications themselves are silent: the sound pack already plays TeamSpeak's own sound
* for either, as it does in the app.
*/
class MessageNotifier(private val context: Context) {
private val manager = context.getSystemService(NotificationManager::class.java)
private val me = Person.Builder().setName("You").build()
/** What each private conversation's notification shows, by [id] of server and peer. */
private val conversations = HashMap<Int, Conversation>()
private class Conversation(val peer: Person, val messages: MutableList<Notification.MessagingStyle.Message>)
init {
manager.createNotificationChannels(listOf(
channel(MESSAGES, R.string.messages_channel),
channel(POKES, R.string.pokes_channel),
))
}
private fun channel(id: String, name: Int) =
NotificationChannel(id, context.getString(name), NotificationManager.IMPORTANCE_HIGH).apply {
setSound(null, null)
enableVibration(true)
}
fun poke(from: String, message: String) {
val notification = Notification.Builder(context, POKES)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle("$from poked you")
.setContentText(message.ifBlank { null })
.setCategory(Notification.CATEGORY_MESSAGE)
.setContentIntent(open(null, null))
.setAutoCancel(true)
.build()
manager.notify(POKE_TAG, from.hashCode(), notification)
}
fun clearPokes() {
for (active in manager.activeNotifications) {
if (active.tag == POKE_TAG) manager.cancel(POKE_TAG, active.id)
}
}
/** A message the peer sent, which starts the conversation's notification if it had none. */
@Synchronized
fun received(serverId: Int, clientId: Int, from: String, text: String) {
val id = id(serverId, clientId)
val conversation = conversations[id]?.takeIf { it.peer.name == from }
?: Conversation(Person.Builder().setName(from).setKey("client:$serverId:$clientId").build(), ArrayList())
conversations[id] = conversation
add(serverId, clientId, conversation, text, conversation.peer, alert = true)
}
/** Our own reply, shown in a conversation already notified of; answers the reply action too. */
@Synchronized
fun sent(serverId: Int, clientId: Int, text: String) {
val conversation = conversations[id(serverId, clientId)] ?: return
add(serverId, clientId, conversation, text, me, alert = false)
}
private fun add(serverId: Int, clientId: Int, conversation: Conversation, text: String, sender: Person, alert: Boolean) {
conversation.messages += Notification.MessagingStyle.Message(text, System.currentTimeMillis(), sender)
if (conversation.messages.size > MAX_MESSAGES) conversation.messages.removeAt(0)
val style = Notification.MessagingStyle(me)
conversation.messages.forEach(style::addMessage)
val notification = Notification.Builder(context, MESSAGES)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setStyle(style)
.setCategory(Notification.CATEGORY_MESSAGE)
.setContentIntent(open(serverId, clientId))
.setOnlyAlertOnce(!alert)
.setAutoCancel(true)
.addAction(replyAction(serverId, clientId))
.build()
manager.notify(MESSAGE_TAG, id(serverId, clientId), notification)
}
/** The conversation came on screen, or ended with the connection. */
@Synchronized
fun clear(serverId: Int, clientId: Int) {
val id = id(serverId, clientId)
conversations.remove(id)
manager.cancel(MESSAGE_TAG, id)
}
/** The server's conversations, as its connection ended. */
@Synchronized
fun clearServer(serverId: Int) {
conversations.keys.filter { it ushr 16 == serverId }.forEach { clear(serverId, it and 0xFFFF) }
}
private fun open(serverId: Int?, clientId: Int?): PendingIntent {
val intent = Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
if (serverId != null && clientId != null) {
intent.putExtra(EXTRA_SERVER, serverId).putExtra(EXTRA_CHAT, clientId)
}
return PendingIntent.getActivity(context, if (serverId != null && clientId != null) id(serverId, clientId) else -1,
intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
private fun replyAction(serverId: Int, clientId: Int): Notification.Action {
val intent = Intent(context, ReplyReceiver::class.java)
.putExtra(EXTRA_SERVER, serverId).putExtra(EXTRA_CHAT, clientId)
// Mutable: the system fills the typed reply into it.
val pending = PendingIntent.getBroadcast(context, id(serverId, clientId), intent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
return Notification.Action.Builder(null, "Reply", pending)
.addRemoteInput(RemoteInput.Builder(KEY_REPLY).setLabel("Message").build())
.setSemanticAction(Notification.Action.SEMANTIC_ACTION_REPLY)
.build()
}
/** Client ids are 16 bits, and only unique on their server. */
private fun id(serverId: Int, clientId: Int) = serverId shl 16 or clientId
companion object {
/** The server and the client id of the private chat a notification opens or replies to. */
const val EXTRA_SERVER = "com.ts3client.android.SERVER"
const val EXTRA_CHAT = "com.ts3client.android.CHAT"
const val KEY_REPLY = "reply"
private const val MESSAGES = "messages"
private const val POKES = "pokes"
private const val MESSAGE_TAG = "message"
private const val POKE_TAG = "poke"
private const val MAX_MESSAGES = 25
}
}

View File

@@ -0,0 +1,220 @@
package com.ts3client.android
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import com.ts3client.session.ServerSession
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.CopyOnWriteArrayList
/**
* Keeps the connections on the network Android routes over.
*
* A TS3 server knows its clients by address, so once the phone moves from Wi-Fi to mobile
* data (or back) an old connection is dead weight: it can only time out. This notices the
* move, says goodbye over the old network while that still works, and connects again over
* the new one, back into the channel we were in. A server that still holds our old
* connection may refuse the new one for a while as a clone, so a failed attempt is retried.
*
* The process's sockets are bound to the network a connection was made over. Otherwise the
* old connection would silently follow the new default network, and its goodbye would come
* from an address the server does not know. A socket keeps the network it was made on, so
* each connection remembers its own.
*/
class NetworkFollower(context: Context) {
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val follows = CopyOnWriteArrayList<Follow>()
/** The system's default network; null while there is none. */
@Volatile
private var network: Network? = null
init {
connectivity.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(available: Network) {
network = available
follows.forEach(Follow::reconsider)
}
override fun onLost(lost: Network) {
if (network == lost) network = null
follows.forEach(Follow::reconsider)
}
})
}
/** Follows [session] until [Follow.forget]. */
fun follow(session: ServerSession, onChange: () -> Unit, system: (String) -> Unit): Follow =
Follow(session, onChange, system).also(follows::add)
/** Binds new sockets to [to], or lets them go by the default network once no connection wants one. */
private fun bind(to: Network?) {
connectivity.bindProcessToNetwork(to ?: follows.firstNotNullOfOrNull { f -> f.bound.takeIf { f.wanted } })
}
inner class Follow internal constructor(
private val session: ServerSession,
private val onChange: () -> Unit,
private val system: (String) -> Unit,
) {
/** The network the connection was made over. */
@Volatile
internal var bound: Network? = null
/** The user wants to be on the server: from their connect until they, or the server, end it. */
@Volatile
internal var wanted = false
private var settle: Job? = null
@Volatile
private var recovery: Job? = null
@Volatile
private var attempt: CompletableDeferred<Boolean>? = null
/** What to show in place of the session's status while getting back on the server. */
@Volatile
var status: String? = null
private set
val isRecovering: Boolean get() = recovery?.isActive == true
/** Before a connect the user asked for, which goes over the network there is now. */
fun connecting() {
recovery?.cancel()
wanted = true
bindHere(connectivity.activeNetwork)
}
/** The user leaves the server. */
fun leaving() {
wanted = false
recovery?.cancel()
unbind()
}
/** The session is thrown away. */
fun forget() {
leaving()
follows.remove(this)
}
fun onConnected() {
attempt?.complete(true)
}
fun onDisconnected() {
val pending = attempt
when {
pending != null -> pending.complete(false)
isRecovering -> {}
// A socket on a network that went away fails at once, before we hear of the change.
wanted && networkGone() -> synchronized(this) {
if (!isRecovering) recovery = recover()
}
// The server let us go (a kick, a shutdown, a failed first connect): leave it at that.
else -> {
wanted = false
unbind()
}
}
}
/** Waits for the network to settle: a handover often shows several changes in a row. */
internal fun reconsider() {
synchronized(this) {
settle?.cancel()
settle = scope.launch {
delay(SETTLE_MS)
synchronized(this@Follow) {
if (wanted && bound != network && !isRecovering) recovery = recover()
}
}
}
}
private fun recover() = scope.launch {
try {
val back = withTimeoutOrNull(GIVE_UP_MS) {
system(if (connectivity.activeNetwork == null) "Network lost." else "Network changed, reconnecting…")
if (session.isBusy) session.connection().disconnectBlocking("Network changed")
var retry = RETRY_MS
while (true) {
val current = awaitNetwork()
bindHere(current)
show("Reconnecting…")
val done = CompletableDeferred<Boolean>()
attempt = done
session.reconnect()
val connected = done.await()
attempt = null
if (connected) break
if (session.connection().connectErrorId == ERROR_BANNED) break
show("Reconnecting, retrying soon…")
delay(retry)
retry = (retry * 2).coerceAtMost(MAX_RETRY_MS)
}
}
if (session.connection().connectErrorId == ERROR_BANNED) {
wanted = false
unbind()
} else if (back == null) {
system("Could not get back on the server.")
wanted = false
unbind()
}
} finally {
attempt = null
status = null
onChange()
// The network may have moved again while we were busy.
reconsider()
}
}
private fun networkGone(): Boolean {
val on = bound ?: return false
return connectivity.getNetworkCapabilities(on) == null || connectivity.activeNetwork != on
}
private suspend fun awaitNetwork(): Network {
while (true) {
connectivity.activeNetwork?.let { return it }
show("Waiting for network…")
delay(POLL_MS)
}
}
private fun show(text: String) {
status = text
onChange()
}
private fun bindHere(to: Network?) {
bound = to
bind(to)
}
private fun unbind() {
bound = null
bind(null)
}
}
private companion object {
const val SETTLE_MS = 1_500L
/** The server's refusal for a banned client; asking again will not help. */
const val ERROR_BANNED = 3329
const val RETRY_MS = 5_000L
const val MAX_RETRY_MS = 30_000L
const val POLL_MS = 500L
const val GIVE_UP_MS = 5 * 60_000L
}
}

View File

@@ -0,0 +1,42 @@
package com.ts3client.android
import android.graphics.ImageDecoder
import android.graphics.drawable.AnimatedImageDrawable
import android.graphics.drawable.BitmapDrawable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import java.nio.ByteBuffer
/** An avatar or a picture from a description: still, or moving like an animated GIF. */
sealed interface Picture {
val width: Int
val height: Int
data class Still(val bitmap: ImageBitmap) : Picture {
override val width get() = bitmap.width
override val height get() = bitmap.height
}
/** Shared by whatever shows it: one animation, drawn wherever it is on screen. */
data class Moving(val drawable: AnimatedImageDrawable) : Picture {
override val width get() = drawable.intrinsicWidth
override val height get() = drawable.intrinsicHeight
}
}
/** Decodes any image Android knows, no larger than [maxPx] on its longer side; null if it is none. */
internal fun decodePicture(data: ByteArray, maxPx: Int): Picture? = try {
val drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(ByteBuffer.wrap(data))) { decoder, info, _ ->
val largest = maxOf(info.size.width, info.size.height)
if (largest > maxPx) {
decoder.setTargetSize(info.size.width * maxPx / largest, info.size.height * maxPx / largest)
}
}
when (drawable) {
is AnimatedImageDrawable -> Picture.Moving(drawable)
is BitmapDrawable -> Picture.Still(drawable.bitmap.asImageBitmap())
else -> null
}
} catch (e: Exception) {
null
}

View File

@@ -0,0 +1,176 @@
package com.ts3client.android
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PixelFormat
import android.provider.Settings
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.WindowManager
/**
* A push-to-talk button floating over other apps. Holding it talks; a double tap whose
* second touch is held moves it instead, so moving never transmits more than the first tap.
*/
class PttOverlay(
private val context: Context,
private val icons: IconTheme,
private val onPressed: (Boolean) -> Unit,
) {
enum class Look { IDLE, TALKING, MUTED }
private val windows = context.getSystemService(WindowManager::class.java)
private val prefs = context.getSharedPreferences("overlay", Context.MODE_PRIVATE)
private val size = (SIZE_DP * context.resources.displayMetrics.density).toInt()
private var button: Button? = null
/** Shows the button, or updates it when it is up; nothing while the app may not draw over others. */
fun show(look: Look) {
button?.let {
it.look = look
return
}
if (!Settings.canDrawOverlays(context)) return
val params = WindowManager.LayoutParams(
size, size,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.START
val screen = windows.currentWindowMetrics.bounds
x = prefs.getInt(POS_X, screen.width() - size - size / 4)
y = prefs.getInt(POS_Y, screen.height() / 2)
}
val view = Button(params).also { it.look = look }
try {
windows.addView(view, params)
} catch (e: WindowManager.BadTokenException) {
// The permission went away since the check.
return
}
button = view
}
fun hide() {
val view = button ?: return
button = null
view.release()
windows.removeView(view)
}
@SuppressLint("ViewConstructor")
private inner class Button(private val params: WindowManager.LayoutParams) : View(context) {
var look = Look.IDLE
set(value) {
field = value
restyle()
}
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
private var pressed = false
private var dragging = false
private var lastTapUp = 0L
private var grabX = 0f
private var grabY = 0f
init {
contentDescription = "Push to talk"
alpha = RESTING_ALPHA
}
/** Half see-through at rest, so it hides less of the app beneath; solid while it talks. */
private fun restyle() {
alpha = if (pressed || look == Look.TALKING) 1f else RESTING_ALPHA
invalidate()
}
override fun onDraw(canvas: Canvas) {
val radius = width / 2f
val lit = pressed || look == Look.TALKING
paint.color = when {
look == Look.MUTED -> MUTED
lit -> TALKING
else -> IDLE
}
canvas.drawCircle(radius, radius, radius, paint)
if (lit) {
paint.color = Color.WHITE
paint.style = Paint.Style.STROKE
paint.strokeWidth = radius / 12
canvas.drawCircle(radius, radius, radius - paint.strokeWidth, paint)
paint.style = Paint.Style.FILL
}
val iconSize = width / 2
icon(iconSize)?.let {
val offset = (width - iconSize) / 2f
canvas.drawBitmap(it, offset, offset, null)
}
}
private fun icon(size: Int): Bitmap? =
icons.renderMaterial(if (look == Look.MUTED) "INPUT_MUTED" else "CAPTURE", size, Color.WHITE)
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(e: MotionEvent): Boolean {
when (e.actionMasked) {
MotionEvent.ACTION_DOWN -> {
dragging = e.eventTime - lastTapUp < doubleTapTimeout
if (dragging) {
grabX = e.rawX - params.x
grabY = e.rawY - params.y
} else {
press(true)
}
}
MotionEvent.ACTION_MOVE -> if (dragging) moveTo(e.rawX - grabX, e.rawY - grabY)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
val tap = !dragging && e.eventTime - e.downTime < doubleTapTimeout
lastTapUp = if (tap) e.eventTime else 0L
if (dragging) {
dragging = false
prefs.edit().putInt(POS_X, params.x).putInt(POS_Y, params.y).apply()
} else {
press(false)
}
}
}
return true
}
private fun moveTo(x: Float, y: Float) {
val screen = windows.currentWindowMetrics.bounds
params.x = x.toInt().coerceIn(0, screen.width() - width)
params.y = y.toInt().coerceIn(0, screen.height() - height)
windows.updateViewLayout(this, params)
}
private fun press(down: Boolean) {
if (pressed == down) return
pressed = down
onPressed(down)
restyle()
}
/** Lets go of the microphone should the button vanish under the finger. */
fun release() = press(false)
}
private companion object {
const val SIZE_DP = 64
const val RESTING_ALPHA = 0.5f
const val POS_X = "x"
const val POS_Y = "y"
val IDLE = Color.rgb(0x1C, 0x6F, 0xB8)
val TALKING = Color.rgb(0x2E, 0x9B, 0xF0)
val MUTED = Color.rgb(0x42, 0x42, 0x42)
}
}

View File

@@ -0,0 +1,17 @@
package com.ts3client.android
import android.app.RemoteInput
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Sends what the user typed into a private message notification. */
class ReplyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val serverId = intent.getIntExtra(MessageNotifier.EXTRA_SERVER, -1)
val clientId = intent.getIntExtra(MessageNotifier.EXTRA_CHAT, -1)
val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(MessageNotifier.KEY_REPLY)?.toString()
if (serverId < 0 || clientId < 0 || text.isNullOrBlank()) return
(context.applicationContext as Ts3Application).sessions.reply(serverId, clientId, text)
}
}

View File

@@ -0,0 +1,39 @@
package com.ts3client.android
import android.content.ContentValues
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Environment
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Puts pictures into the shared Pictures collection, in a TeamSpeak album, where galleries find them. */
object SavedPictures {
/** Saves [data] as [name], its extension following what the data is; false if it could not be. */
suspend fun save(context: Context, name: String, data: ByteArray): Boolean = withContext(Dispatchers.IO) {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(data, 0, data.size, bounds)
val mime = bounds.outMimeType ?: return@withContext false
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime)
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, if (extension == null) name else "$name.$extension")
put(MediaStore.Images.Media.MIME_TYPE, mime)
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TeamSpeak")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val resolver = context.contentResolver
val uri = resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values)
?: return@withContext false
try {
resolver.openOutputStream(uri)!!.use { it.write(data) }
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
true
} catch (e: Exception) {
resolver.delete(uri, null, null)
false
}
}
}

View File

@@ -0,0 +1,469 @@
package com.ts3client.android
import com.ts3client.contacts.ContactType
import com.ts3client.net.ChannelNode
import com.ts3client.net.Details
import com.ts3client.net.GroupChoice
import com.ts3client.net.ban.BanEntry
import com.ts3client.net.ban.BanRule
import com.ts3client.net.ConnectionListener.ChatScope
import com.ts3client.session.ServerSession
import com.ts3client.text.BBCode
import com.ts3client.text.ChatHtml
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* One server the app is on: its [ServerSession], and the snapshots the UI shows of it. Session
* callbacks arrive on connection threads and are turned into immutable snapshots.
*/
class ServerController internal constructor(
/** Unique among the servers of this process run. */
val id: Int,
private val app: SessionController,
) {
internal val session = ServerSession(
app.settings, app.identityStore, app.audio, app.sounds, app.contactStore, Listener()
)
internal val conn get() = session.connection()
private val network = app.network.follow(session, ::refresh, ::system)
/** Whether this server holds the microphone; set by [SessionController] as it moves. */
@Volatile
internal var micOwner = false
/** Set once connecting began, so a session that never started is not taken for one that ended. */
@Volatile
private var started = false
/** Set when the user left, so the end needs no telling. */
@Volatile
internal var left = false
private val _state = MutableStateFlow(SessionState())
val state: StateFlow<SessionState> = _state
private val _tree = MutableStateFlow(ServerTree())
val tree: StateFlow<ServerTree> = _tree
private val _chats = MutableStateFlow(Chats())
val chats: StateFlow<Chats> = _chats
/** Channels the user folded away in the tree. */
private val _collapsed = MutableStateFlow(emptySet<Int>())
val collapsed: StateFlow<Set<Int>> = _collapsed
/** The conversation on screen, whose new lines are read as they come. */
@Volatile
var viewing: ChatKey? = null
set(value) {
field = value
if (value != null) markRead(value)
if (value is ChatKey.Private) app.notifier.clear(id, value.clientId)
}
/** Counts the answers to info requests, for the details on screen to be read again. */
private val _infoUpdates = MutableStateFlow(0)
val infoUpdates: StateFlow<Int> = _infoUpdates
val images = ServerImages(conn)
/** A private chat a notification asked to show, until the UI took it up. */
private val _chatRequest = MutableStateFlow<ChatKey.Private?>(null)
val chatRequest: StateFlow<ChatKey.Private?> = _chatRequest
private val lineIds = AtomicLong()
/** Tree snapshots are taken one at a time, at most once per [TREE_FRAME_MS]. */
private val treeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1))
private val treePending = AtomicBoolean()
private val structureChanged = AtomicBoolean(true)
private var roots: List<ChannelNode> = emptyList()
val isConnected: Boolean get() = session.isConnected
internal val isBusy: Boolean get() = session.isBusy || network.isRecovering
internal val microphone get() = if (session.isConnected) conn.microphone else null
internal val playback get() = if (session.isConnected) conn.playback else null
/** Not on the server and not trying to get back on it: done with. */
internal val ended: Boolean get() = started && !isBusy
// ---- connection ----
internal fun start(host: String, port: Int, nickname: String, password: String, identityId: String,
channel: String, channelPassword: String) {
network.connecting()
started = true
session.connect(host, port, nickname, password, identityId, channel, channelPassword)
refresh()
}
fun disconnect() {
left = true
network.leaving()
session.disconnect()
}
/** Before the app goes away with the connection, which [SessionController.leave] then ends. */
internal fun leaving() {
left = true
network.leaving()
}
internal fun dispose() {
network.forget()
treeScope.cancel()
session.dispose()
}
// ---- the local client ----
fun setMicMuted(muted: Boolean) = session.setMicMuted(muted)
fun setDeafened(deafened: Boolean) = session.setDeafened(deafened)
fun setAway(away: Boolean) = session.setAway(away, null)
// ---- channels and clients ----
fun joinChannel(channelId: Int, password: String? = null) {
if (session.isConnected) conn.joinChannel(channelId, password)
}
fun setSubscribed(channelId: Int, subscribed: Boolean) {
if (session.isConnected) conn.setChannelsSubscribed(listOf(channelId), subscribed)
}
fun toggleCollapsed(channelId: Int) =
_collapsed.update { if (channelId in it) it - channelId else it + channelId }
fun expand(channelIds: Collection<Int>) = _collapsed.update { it - channelIds.toSet() }
val canMoveClients: Boolean get() = session.canMoveClients()
fun moveClient(clientId: Int, channelId: Int) {
if (session.isConnected) conn.moveClient(clientId, channelId, null)
}
fun poke(clientId: Int, message: String) {
if (session.isConnected) conn.poke(clientId, message)
}
/** The client's playback volume change in dB; 0 when unchanged. */
fun clientVolume(clientId: Int): Double =
conn.model.getClient(clientId)?.let { conn.getClientVolumeModifier(it) } ?: 0.0
fun setClientVolume(clientId: Int, decibels: Double) {
conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) }
}
// ---- moderation ----
val canKickFromChannel: Boolean get() = session.canKickFromChannel()
val canKickFromServer: Boolean get() = session.canKickFromServer()
val canBanClients: Boolean get() = session.canBanClients()
val canViewBanList: Boolean get() = session.canViewBanList()
val canCreateBans: Boolean get() = session.isConnected && conn.model.permits("b_client_ban_create")
/** The longest ban we may hand out in seconds; 0 or -1 for no limit. */
val maxBanSeconds: Long get() = conn.model.maxBanSeconds()
fun kickFromChannel(clientId: Int, reason: String) {
if (session.isConnected) conn.kickFromChannel(clientId, reason)
}
fun kickFromServer(clientId: Int, reason: String) {
if (session.isConnected) conn.kickFromServer(clientId, reason)
}
/** Bans a client for [seconds], or for good with 0. */
fun ban(clientId: Int, seconds: Long, reason: String) {
if (session.isConnected) conn.banClient(clientId, seconds, reason)
}
fun serverGroupChoices(clientId: Int): List<GroupChoice> =
conn.model.getClient(clientId)?.let(conn.model::serverGroupChoices).orEmpty()
fun channelGroupChoices(clientId: Int): List<GroupChoice> =
conn.model.getClient(clientId)?.let(conn.model::channelGroupChoices).orEmpty()
fun setServerGroup(clientId: Int, groupId: Int, assign: Boolean) {
val client = conn.model.getClient(clientId) ?: return
if (session.isConnected) conn.setClientServerGroup(client.databaseId, groupId, assign)
}
/** Gives a client [groupId] in the channel they are in. */
fun setChannelGroup(clientId: Int, groupId: Int) {
val client = conn.model.getClient(clientId) ?: return
if (session.isConnected) conn.setClientChannelGroup(client.databaseId, client.channelId, groupId)
}
// ---- ban list ----
/** The server's bans, or why they could not be read. */
suspend fun banList(): Result<List<BanEntry>> = suspendCoroutine { done ->
conn.requestBanList { bans, error ->
done.resume(if (error == null) Result.success(bans) else Result.failure(IllegalStateException(error)))
}
}
/** Each of these answers null once done, else why not. */
suspend fun addBan(rule: BanRule): String? = suspendCoroutine { done -> conn.addBan(rule) { done.resume(it) } }
suspend fun replaceBan(banId: Int, rule: BanRule): String? =
suspendCoroutine { done -> conn.replaceBan(banId, rule) { done.resume(it) } }
suspend fun deleteBans(banIds: Collection<Int>): String? =
suspendCoroutine { done -> conn.deleteBans(banIds) { done.resume(it) } }
fun canDeleteBans(bans: Collection<BanEntry>): Boolean = conn.model.canDeleteBans(bans, conn.selfUniqueId)
fun canEditBan(ban: BanEntry): Boolean = conn.model.canEditBan(ban, conn.selfUniqueId)
fun isOwnBan(ban: BanEntry): Boolean = ban.createdBy(conn.selfUniqueId)
/** Uploads [data] as our avatar; null once it is set, else why not. */
suspend fun setAvatar(data: ByteArray): String? {
if (!session.isConnected) return "Not connected"
val file = withContext(Dispatchers.IO) {
File.createTempFile("avatar", null, app.cacheDir).apply { writeBytes(data) }
}
return try {
suspendCoroutine { done -> conn.setAvatar(file) { done.resume(it) } }
} finally {
file.delete()
}
}
// ---- details ----
fun serverDetails(): Details? =
if (session.isConnected) Details.server(conn.model, "${conn.serverHost}:${conn.serverPort}") else null
fun channelDetails(channelId: Int): Details? = conn.model.getChannel(channelId)?.let(Details::channel)
fun clientDetails(clientId: Int): Details? = conn.model.getClient(clientId)?.let {
Details.client(it, conn.model, app.contactStore.find(it.uniqueId))
}
/** Asks the server for what the channel's details lack; they change once it answers. */
fun requestChannelDetails(channelId: Int) {
val channel = conn.model.getChannel(channelId) ?: return
if (session.isConnected && !channel.descriptionLoaded) conn.requestChannelInfo(channelId)
}
fun requestClientDetails(clientId: Int) {
if (session.isConnected) conn.requestClientInfo(clientId)
}
/** Files a client on the server as a contact; a new entry takes the type's defaults. */
fun addContact(clientId: Int, type: ContactType) {
val client = conn.model.getClient(clientId) ?: return
app.contactStore.add(client.uniqueId, client.nickname, type, app.settings.contactDefaults)
}
internal fun contactsChanged() {
treeScope.launch { conn.applyContacts() }
scheduleTree(structure = false)
}
// ---- chat ----
/** Opens the private chat with a client, loading what was said before. */
fun openPrivateChat(clientId: Int): ChatKey.Private {
val key = ChatKey.Private(clientId)
val client = conn.model.getClient(clientId)
if (client != null) {
_chats.update { chats ->
chats.update(key, conn.displayName(client)) { it.copy(uniqueId = client.uniqueId) }
}
conn.loadPrivateChatHistory(clientId, client.uniqueId)
}
return key
}
fun closePrivateChat(key: ChatKey.Private) = _chats.update { it.close(key) }
fun send(key: ChatKey, text: String) {
when (key) {
ChatKey.Server -> session.sendChat(ChatScope.SERVER, 0, text)
ChatKey.Channel -> session.sendChat(ChatScope.CHANNEL, 0, text)
is ChatKey.Private -> session.sendChat(ChatScope.PRIVATE, key.clientId, text)
}
}
fun markRead(key: ChatKey) {
if (_chats.value[key]?.unread == true) _chats.update { chats -> chats.update(key) { it.copy(unread = false) } }
}
internal fun requestChat(clientId: Int) {
_chatRequest.value = ChatKey.Private(clientId)
}
fun chatRequestHandled() {
_chatRequest.value = null
}
/** Answers a private message from its notification. */
internal fun reply(clientId: Int, text: String) {
if (session.isConnected) send(ChatKey.Private(clientId), text) else app.notifier.clear(id, clientId)
}
private fun append(key: ChatKey, title: String, html: String) {
if (html.isEmpty()) return
val line = ChatLine(lineIds.incrementAndGet(), html)
_chats.update { chats -> chats.update(key, title) { it.append(line, read = viewing == key) } }
}
private fun setHistory(key: ChatKey, title: String, entries: List<String>) {
val lines = entries.map(ChatHtml::logEntry).filter { it.isNotEmpty() }
.map { ChatLine(lineIds.incrementAndGet(), it) }
_chats.update { chats -> chats.update(key, title) { it.copy(lines = lines) } }
}
private fun system(text: String) = append(ChatKey.Server, "", ChatHtml.system(text))
// ---- snapshots ----
private fun snapshotState(): SessionState {
val self = conn.model.getClient(conn.selfClientId)
return SessionState(
title = session.title(),
status = network.status ?: session.status(),
connected = session.isConnected,
busy = isBusy,
micActive = micOwner && app.hasMicPermission(),
micPermission = app.hasMicPermission(),
micMuted = session.isMicMuted,
deafened = session.isDeafened,
away = session.isAway,
selfClientId = if (session.isConnected) conn.selfClientId else 0,
channelId = if (session.isConnected) self?.channelId ?: 0 else 0,
inputMode = app.settings.inputMode,
hostButton = if (session.isConnected) conn.model.hostButton() else null,
self = session.selfState(),
)
}
internal fun refresh() {
_state.value = snapshotState()
app.serverChanged(this)
}
/** Takes a new tree snapshot soon; [structure] when channels or clients came, went or moved. */
internal fun scheduleTree(structure: Boolean) {
if (structure) structureChanged.set(true)
if (!treePending.compareAndSet(false, true)) return
treeScope.launch {
delay(TREE_FRAME_MS)
treePending.set(false)
if (structureChanged.getAndSet(false)) roots = conn.model.buildTree()
_tree.value = ServerTree.of(roots, conn.model, conn.contacts, app.settings.colorContacts,
app.settings.countryFlags)
val serverName = if (session.isConnected) conn.model.serverName.orEmpty() else ""
val channelName = session.currentChannel()?.name.orEmpty()
_chats.update {
it.copy(
server = it.server.copy(title = serverName.ifBlank { "Server" }),
channel = it.channel.copy(title = channelName.ifBlank { "Channel" }),
)
}
refresh()
}
}
private inner class Listener : ServerSession.Listener {
override fun onSessionChanged() = refresh()
override fun onSystemMessage(text: String) = system(text)
override fun onOwnChat(scope: ChatScope, peerClientId: Int, peerName: String, text: String) {
val self = conn.model.getClient(conn.selfClientId)?.nickname ?: app.settings.nickname
val html = ChatHtml.message(conn.selfClientId, conn.selfUniqueId, "$self (you)", text)
append(key(scope, peerClientId), peerName, html)
if (scope == ChatScope.PRIVATE) app.notifier.sent(id, peerClientId, BBCode.toPlain(text))
}
override fun onStatus(status: String) {}
override fun onConnected() {
network.onConnected()
app.connected(this@ServerController)
refresh()
}
override fun onDisconnected(reason: String?) {
network.onDisconnected()
app.notifier.clearServer(id)
_chats.update { it.copy(private = emptyList()) }
app.disconnected(this@ServerController)
scheduleTree(structure = true)
refresh()
}
override fun onModelChanged() {
scheduleTree(structure = true)
// A server edit may have changed it.
if (session.isConnected && conn.model.hostButton() != _state.value.hostButton) refresh()
}
override fun onServerIconChanged(iconId: Long) =
app.serverIconChanged(session.address(), session.port(), iconId)
override fun onInfoUpdated() {
_infoUpdates.update { it + 1 }
// Avatars announce their arrival as info updates.
images.loaded()
}
override fun onIconsUpdated() = images.loaded()
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) {
append(key(scope, fromClientId), fromName, ChatHtml.message(fromClientId, fromUniqueId, fromName, message))
if (scope == ChatScope.PRIVATE && !app.foreground) {
app.notifier.received(id, fromClientId, fromName, BBCode.toPlain(message))
}
}
override fun onChatHistory(scope: ChatScope, htmlEntries: List<String>) {
if (scope != ChatScope.PRIVATE) setHistory(key(scope, 0), "", htmlEntries)
}
override fun onPrivateChatHistory(clientId: Int, nickname: String, htmlEntries: List<String>) =
setHistory(ChatKey.Private(clientId), nickname, htmlEntries)
override fun onTalkStateChanged(clientId: Int, talking: Boolean) {
if (clientId == conn.selfClientId && micOwner) app.talking(talking)
scheduleTree(structure = false)
}
override fun onError(message: String) = app.error(message)
override fun onPoke(fromName: String, message: String) = app.poked(fromName, message)
override fun onServerLog(message: String) = append(ChatKey.Server, "", ChatHtml.serverLog(message))
override fun onServerLogHtml(html: String) = append(ChatKey.Server, "", ChatHtml.logEntry(html))
}
private fun key(scope: ChatScope, peerClientId: Int): ChatKey = when (scope) {
ChatScope.SERVER -> ChatKey.Server
ChatScope.CHANNEL -> ChatKey.Channel
ChatScope.PRIVATE -> ChatKey.Private(peerClientId)
}
private companion object {
const val TREE_FRAME_MS = 30L
}
}

View File

@@ -0,0 +1,61 @@
package com.ts3client.android
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import com.ts3client.net.TeamspeakConnection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import java.util.concurrent.ConcurrentHashMap
/**
* One server's icons and its clients' avatars, decoded once. What is still
* downloading reads as null until [version] moves on.
*/
class ServerImages internal constructor(private val conn: TeamspeakConnection) {
private val icons = ConcurrentHashMap<Long, ImageBitmap>()
private val avatars = ConcurrentHashMap<String, Picture>()
private val _version = MutableStateFlow(0)
val version: StateFlow<Int> = _version
internal fun loaded() = _version.update { it + 1 }
fun icon(id: Long): ImageBitmap? {
if (id == 0L) return null
icons[id]?.let { return it }
val bitmap = conn.icons.get(id)?.let { decode(it, ICON_PX) } ?: return null
icons[id] = bitmap
return bitmap
}
fun avatar(uniqueId: String, flag: String): Picture? {
if (flag.isEmpty()) return null
avatars[flag]?.let { return it }
val picture = conn.avatars.get(uniqueId, flag)?.let { decodePicture(it.data(), AVATAR_PX) } ?: return null
avatars[flag] = picture
return picture
}
/** The avatar's file as the server keeps it; null while it has none or it is still downloading. */
fun avatarData(uniqueId: String, flag: String): ByteArray? =
if (flag.isEmpty()) null else conn.avatars.get(uniqueId, flag)?.data()
/** Decoded no larger than needed: avatars may be far bigger than they are ever shown. */
private fun decode(data: ByteArray, maxPx: Int): ImageBitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(data, 0, data.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxPx) sample *= 2
val options = BitmapFactory.Options().apply { inSampleSize = sample }
return BitmapFactory.decodeByteArray(data, 0, data.size, options)?.asImageBitmap()
}
private companion object {
const val ICON_PX = 64
const val AVATAR_PX = 512
}
}

View File

@@ -0,0 +1,185 @@
package com.ts3client.android
import androidx.compose.runtime.Immutable
import com.ts3client.contacts.ContactStore
import com.ts3client.contacts.ContactType
import com.ts3client.net.ChannelNode
import com.ts3client.net.ClientEntry
import com.ts3client.net.ClientStatus
import com.ts3client.net.ServerModel
import com.ts3client.net.Spacers
import com.ts3client.net.TreeMatch
import com.ts3client.text.TextQuery
/**
* A copy of the channel tree taken at one moment. The model's own nodes change in place
* on connection threads, which Compose cannot observe; these never change.
*/
@Immutable
data class ServerTree(
val channels: List<ChannelItem> = emptyList(),
/** The server's own icon; 0 for none. */
val serverIconId: Long = 0,
/** Whether client rows show their country's flag. */
val countryFlags: Boolean = false,
) {
fun channel(id: Int): ChannelItem? = find(channels) { it.id == id }
fun client(id: Int): ClientItem? {
var found: ClientItem? = null
find(channels) { c -> found = c.clients.firstOrNull { it.id == id }; found != null }
return found
}
/** The channels from the root down to the one [clientId] is in; null when they are not in the tree. */
fun pathTo(clientId: Int): List<Int>? {
fun walk(items: List<ChannelItem>): List<Int>? {
for (c in items) {
if (c.clients.any { it.id == clientId }) return listOf(c.id)
walk(c.children)?.let { return listOf(c.id) + it }
}
return null
}
return walk(channels)
}
/** The channels from the root down to [channelId]'s parent; null when it is not in the tree. */
fun parentsOf(channelId: Int): List<Int>? {
fun walk(items: List<ChannelItem>, above: List<Int>): List<Int>? {
for (c in items) {
if (c.id == channelId) return above
walk(c.children, above + c.id)?.let { return it }
}
return null
}
return walk(channels, emptyList())
}
/** The ids of what [find] matches, in reading order. */
fun hits(find: TreeFind): List<Int> {
val out = ArrayList<Int>()
fun walk(items: List<ChannelItem>) {
for (c in items) {
if (find.matches(c)) out += c.id
c.clients.forEach { if (find.matches(it)) out += it.id }
walk(c.children)
}
}
if (!find.query.isEmpty) walk(channels)
return out
}
/** Only what [find] matches: a matching channel with its clients, a matching client, and the channels leading to either. */
fun only(find: TreeFind): ServerTree {
fun keep(c: ChannelItem): ChannelItem? {
val matches = find.matches(c)
val clients = if (matches) c.clients else c.clients.filter(find::matches)
val children = c.children.mapNotNull(::keep)
return if (matches || clients.isNotEmpty() || children.isNotEmpty()) c.copy(clients = clients, children = children) else null
}
return copy(channels = channels.mapNotNull(::keep))
}
private fun find(items: List<ChannelItem>, test: (ChannelItem) -> Boolean): ChannelItem? {
for (c in items) {
if (test(c)) return c
find(c.children, test)?.let { return it }
}
return null
}
companion object {
fun of(roots: List<ChannelNode>, model: ServerModel, contacts: ContactStore, colorContacts: Boolean,
countryFlags: Boolean): ServerTree =
ServerTree(
roots.map { channel(it, model, contacts, colorContacts) },
model.serverIconId(),
countryFlags,
)
private fun channel(c: ChannelNode, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ChannelItem {
val clients = c.clients
val spacer = Spacers.parse(c.name)
return ChannelItem(
id = c.id,
name = c.name ?: "",
spacer = spacer,
iconId = if (spacer == null) c.iconId else 0,
locked = c.hasPassword,
full = c.maxClients >= 0 && clients.size >= c.maxClients,
subscribed = c.subscribed,
clients = clients.map { client(it, model, contacts, colorContacts) },
children = c.children.map { channel(it, model, contacts, colorContacts) },
)
}
private fun client(c: ClientEntry, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ClientItem {
val contact = contacts.find(c.uniqueId)
val away = c.away && c.awayMessage.isNotEmpty() && contact?.ignoreAwayMessage != true
return ClientItem(
id = c.id,
uniqueId = c.uniqueId,
nickname = c.nickname ?: "",
name = contact?.displayName(c.nickname) ?: c.nickname ?: "",
awayMessage = if (away) c.awayMessage else "",
status = ClientStatus.of(c),
self = c.self,
contactType = contact?.type,
highlight = if (colorContacts) contact?.type ?: ContactType.NEUTRAL else ContactType.NEUTRAL,
groupIconIds = (model.serverGroupsOf(c.serverGroupIds) + listOfNotNull(model.channelGroup(c.channelGroupId)))
.map { it.iconId }.filter { it != 0L },
avatarFlag = c.avatarFlag.orEmpty(),
country = c.country.orEmpty(),
serverGroupIds = c.serverGroupIds.toList(),
channelGroupId = c.channelGroupId,
)
}
}
}
/** What the find bar looks for in the tree: clients, or with [channels] channels. */
@Immutable
data class TreeFind(val query: TextQuery, val channels: Boolean) {
fun matches(c: ChannelItem) = channels && TreeMatch.channel(query, c.name)
fun matches(c: ClientItem) = !channels && TreeMatch.client(query, c.nickname, c.name)
}
@Immutable
data class ChannelItem(
val id: Int,
val name: String,
/** Set for a spacer: a cosmetic separator rather than a channel to join. */
val spacer: Spacers.Spacer?,
val locked: Boolean,
val full: Boolean,
val subscribed: Boolean,
/** 0 for none. */
val iconId: Long,
val clients: List<ClientItem>,
val children: List<ChannelItem>,
)
@Immutable
data class ClientItem(
val id: Int,
val uniqueId: String,
val nickname: String,
/** The name shown: the contact's own name for them where there is one. */
val name: String,
val awayMessage: String,
val status: ClientStatus,
val self: Boolean,
/** How the client is filed in the contacts; null when it is not one. */
val contactType: ContactType?,
/** The contact colour the name is drawn in, NEUTRAL when contacts are not coloured. */
val highlight: ContactType,
/** The icons of the client's server groups, then of its channel group, as TeamSpeak lines them up. */
val groupIconIds: List<Long>,
/** The MD5 of the client's avatar; empty for none. */
val avatarFlag: String,
/** The code of the country the server places the client in; empty when it cannot tell. */
val country: String,
val serverGroupIds: List<Int>,
val channelGroupId: Int,
)

View File

@@ -0,0 +1,475 @@
package com.ts3client.android
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
import com.ts3client.android.audio.AndroidAudioBackend
import com.ts3client.android.audio.AudioRoute
import com.ts3client.audio.AudioDevice
import com.ts3client.config.BanReasonPresets
import com.ts3client.config.Bookmark
import com.ts3client.config.Bookmarks
import com.ts3client.config.IdentityStore
import com.ts3client.config.Settings
import com.ts3client.contacts.Contact
import com.ts3client.contacts.ContactStore
import com.ts3client.myts.MyTeamSpeak
import com.ts3client.net.HostButton
import com.ts3client.myts.MyTeamSpeakLogin
import com.ts3client.session.SelfState
import com.ts3client.session.ServerSession
import com.ts3client.session.Sessions
import com.ts3client.sound.SoundNotifier
import com.ts3client.sound.SoundPack
import com.ts3client.teamspeak.SyncItem
import com.ts3client.teamspeak.TeamSpeakImporter
import com.ts3client.teamspeak.TeamSpeakSettingsDb
import com.ts3client.text.BBCode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
/** What the app shows of a connection and the local client on it. */
data class SessionState(
val title: String = "",
val status: String = "Not connected",
val connected: Boolean = false,
val busy: Boolean = false,
/** Whether this server holds the microphone, which only one server can at a time. */
val micActive: Boolean = false,
val micPermission: Boolean = false,
val micMuted: Boolean = false,
val deafened: Boolean = false,
val away: Boolean = false,
val selfClientId: Int = 0,
val channelId: Int = 0,
val inputMode: Settings.InputMode = Settings.InputMode.VOICE_ACTIVATION,
val hostButton: HostButton? = null,
/** What the tree shows for the local client, and the connection notification too. */
val self: SelfState = SelfState.DISCONNECTED,
)
/** Something that happened once, for the UI to tell the user about. */
sealed interface SessionEvent {
data class Poke(val from: String, val message: String) : SessionEvent
data class Error(val message: String) : SessionEvent
}
/**
* The servers the app is on and what they share, outliving any activity: [ConnectionService]
* keeps the process in the foreground while any is connected. Only one server captures with
* the microphone at a time; [Sessions] hands it over.
*/
class SessionController(private val context: Context) {
val settings: Settings = Settings.load()
val icons = IconTheme(context, settings)
val pictures = WebPictures(context.cacheDir, settings)
internal val cacheDir: File get() = context.cacheDir
private val bookmarkStore = Bookmarks.load()
internal val identityStore = IdentityStore.load(settings)
val identities = IdentityManager(context, identityStore, settings, bookmarkStore) {
_bookmarks.value = bookmarkStore.all().toList()
}
internal val audio = AndroidAudioBackend(context)
private val soundPlayer = SpeakingSoundPlayer(context, audio.createSoundPlayer(settings))
internal val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
internal val notifier = MessageNotifier(context)
internal val contactStore = ContactStore.load()
/** The reasons offered when adding a ban; shared by every server. */
val banReasons: BanReasonPresets by lazy { BanReasonPresets.load() }
internal val network = NetworkFollower(context)
private val main = Handler(Looper.getMainLooper())
private val nextId = AtomicInteger()
/** Touched on the main thread only, as [Sessions] wants. */
private val sessions = Sessions { main.post(::microphoneMoved) }
private val _servers = MutableStateFlow(emptyList<ServerController>())
/** Every server the app is on or getting on, in the order they were connected. */
val servers: StateFlow<List<ServerController>> = _servers
private val _current = MutableStateFlow<ServerController?>(null)
/** The server on screen; null when there is none. */
val current: StateFlow<ServerController?> = _current
private val _micOwner = MutableStateFlow<ServerController?>(null)
/** The server capturing with the microphone; null when none is. */
val micOwner: StateFlow<ServerController?> = _micOwner
/** Why the last connection that ended did, for the connect form to show; null when it was left. */
private val _lastEnd = MutableStateFlow<String?>(null)
val lastEnd: StateFlow<String?> = _lastEnd
/** Copies of the contacts, as the store's own entries change in place. */
private val _contacts = MutableStateFlow(contactCopies())
val contacts: StateFlow<List<Contact>> = _contacts
private val _bookmarks = MutableStateFlow(bookmarkStore.all().toList())
val bookmarks: StateFlow<List<Bookmark>> = _bookmarks
private val _events = MutableSharedFlow<SessionEvent>(extraBufferCapacity = 16)
val events: SharedFlow<SessionEvent> = _events
private val _onScreen = MutableStateFlow(false)
val onScreen: StateFlow<Boolean> = _onScreen
/** Whether the app is on screen; pokes and private messages are notified of while it is not. */
var foreground: Boolean
get() = _onScreen.value
set(value) {
_onScreen.value = value
if (value) notifier.clearPokes()
// The process may be let go of any time once out of sight.
else contactStore.flush()
}
private val levels = LevelMonitor(settings, audio)
val micLevel: StateFlow<MicLevel> = levels.level
@Volatile
private var watchingLevel = false
private val routePrefs = AudioRoutePrefs(context)
private val _route = MutableStateFlow(routePrefs.load())
val route: StateFlow<AudioRoute> = _route
private val _inputDevices = MutableStateFlow(audio.io().inputDevices())
val inputDevices: StateFlow<List<AudioDevice>> = _inputDevices
private val _outputDevices = MutableStateFlow(audio.io().outputDevices())
val outputDevices: StateFlow<List<AudioDevice>> = _outputDevices
val unprocessedSupported: Boolean get() = audio.unprocessedSupported()
init {
audio.setRoute(_route.value)
audio.addDeviceListener {
_inputDevices.value = audio.io().inputDevices()
_outputDevices.value = audio.io().outputDevices()
}
// Mutations report on whichever thread made them, a connection thread included.
contactStore.addListener {
_contacts.value = contactCopies()
_servers.value.forEach(ServerController::contactsChanged)
}
}
/** The nickname a connect with [identityId] goes by: the identity's own, else the global one. */
fun nicknameFor(identityId: String): String = identities.nicknameOf(identityId).ifBlank { settings.nickname }
val lastAddress: String get() = settings.lastAddress
// ---- connection ----
/** Connects to `host[:port]`, as another server; an empty [identityId] means the default identity. */
fun connect(address: String, nickname: String, password: String = "", identityId: String = "") {
val (host, port) = parseAddress(address)
settings.lastAddress = address
if (identities.nicknameOf(identityId).isBlank()) settings.nickname = nickname
settings.save()
start(host, port, nickname, password, identityId, "", "")
}
fun connect(bookmark: Bookmark) {
start(bookmark.address, bookmark.port, bookmark.nickname.orEmpty(), bookmark.password, bookmark.identityId,
bookmark.channel, bookmark.channelPassword)
}
private fun start(host: String, port: Int, nickname: String, password: String, identityId: String,
channel: String, channelPassword: String) {
val server = ServerController(nextId.incrementAndGet(), this)
sessions.add(server.session)
_servers.update { it + server }
_current.value = server
_lastEnd.value = null
context.startForegroundService(Intent(context, ConnectionService::class.java))
server.start(host, port, nickname, password, identityId, channel, channelPassword)
}
fun select(server: ServerController) {
if (server in _servers.value) _current.value = server
}
/** Leaves every server before returning, for when the app goes away with the connections. */
fun leave() {
val servers = _servers.value
servers.forEach(ServerController::leaving)
ServerSession.shutdownAll(servers.map { it.session })
contactStore.flush()
}
fun server(id: Int): ServerController? = _servers.value.firstOrNull { it.id == id }
/** A server's state changed; one that is done with goes from the list. */
internal fun serverChanged(server: ServerController) {
if (server.micOwner) updateLevels()
if (server.ended) main.post { remove(server) }
}
private fun remove(server: ServerController) {
if (server !in _servers.value || !server.ended) return
sessions.remove(server.session)
val rest = _servers.value - server
_servers.value = rest
if (_current.value == server) _current.value = rest.lastOrNull()
server.dispose()
val reason = server.state.value.status
_lastEnd.value = if (server.left) null else reason
if (!server.left && rest.isNotEmpty()) error("${server.state.value.title}: $reason")
}
internal fun connected(server: ServerController) {
// The first connect generates an identity when there was none.
identities.refresh()
main.post {
// Without the permission yet, the UI hands the microphone over once it is granted.
if (hasMicPermission()) sessions.connected(server.session)
}
}
internal fun disconnected(server: ServerController) {
main.post { sessions.disconnected(server.session) }
}
internal fun error(message: String) {
_events.tryEmit(SessionEvent.Error(message))
}
internal fun poked(from: String, message: String) {
_events.tryEmit(SessionEvent.Poke(from, message))
if (!foreground) notifier.poke(from, BBCode.toPlain(message))
}
internal fun talking(transmitting: Boolean) = levels.talking(transmitting)
// ---- bookmarks ----
fun addBookmark(label: String, address: String, nickname: String, password: String, identityId: String) {
val (host, port) = parseAddress(address)
// Only a nickname other than the identity's is the server's own.
val own = nickname.takeIf { it != nicknameFor(identityId) }.orEmpty()
bookmarkStore.add(Bookmark(label.ifBlank { address }, host, port, own, password).also {
it.identityId = identityId
})
saveBookmarks()
}
/** Connects the bookmark with another identity; empty for the default one. */
fun setBookmarkIdentity(bookmark: Bookmark, identityId: String) {
val index = bookmarkStore.all().indexOf(bookmark)
if (index < 0) return
// A new entry, so the list the UI holds changes along with it.
bookmarkStore.all()[index] = bookmark.copy().also { it.identityId = identityId }
saveBookmarks()
}
fun removeBookmark(bookmark: Bookmark) {
val index = bookmarkStore.all().indexOf(bookmark)
if (index < 0) return
bookmarkStore.remove(index)
saveBookmarks()
}
/** Bookmarks leading to [address]:[port] take on the icon the server shows now. */
internal fun serverIconChanged(address: String, port: Int, iconId: Long) {
main.post {
val all = bookmarkStore.all()
var changed = false
for (i in all.indices) {
val b = all[i]
if (!b.pointsAt(address, port) || b.iconId == iconId) continue
// A new entry, so the list the UI holds changes along with it.
all[i] = b.copy().also { it.iconId = iconId }
changed = true
}
if (changed) saveBookmarks()
}
}
private fun saveBookmarks() {
bookmarkStore.save()
_bookmarks.value = bookmarkStore.all().toList()
}
// ---- contacts ----
private fun contactCopies(): List<Contact> = contactStore.all().map { it.copy() }
/** A copy of the contact to edit and hand back to [saveContact]. */
fun contact(uniqueId: String): Contact? = contactStore.find(uniqueId)?.copy()
fun saveContact(contact: Contact) = contactStore.put(contact)
fun removeContact(uniqueId: String) = contactStore.remove(uniqueId)
/**
* Adds the contacts of a TeamSpeak 3 `settings.db`, overwriting entries for the same clients.
*
* @return how many contacts the file held and how many of them were new
*/
suspend fun importContacts(uri: Uri): Pair<Int, Int> = withContext(Dispatchers.IO) {
val found = withLocalCopy(context, uri, TeamSpeakSettingsDb::readContacts)
found.size to if (found.isEmpty()) 0 else contactStore.merge(found)
}
// ---- myTeamSpeak ----
/** The account the app stays signed in to; its reads block, so they run on [Dispatchers.IO]. */
val myTeamSpeak: MyTeamSpeakLogin = MyTeamSpeakLogin.load()
private val importer = TeamSpeakImporter(identityStore, bookmarkStore, settings)
suspend fun signInMyTeamSpeak(email: String, password: String): MyTeamSpeak.Account =
withContext(Dispatchers.IO) { myTeamSpeak.signIn(email, password) }
suspend fun fetchMyTeamSpeak(): MyTeamSpeak.Account = withContext(Dispatchers.IO) { myTeamSpeak.fetch() }
fun isImported(item: SyncItem): Boolean = importer.isPresent(item)
/** Imports [chosen] out of the account's [all] items, with the identities the chosen bookmarks use. */
suspend fun importMyTeamSpeak(all: List<SyncItem>, chosen: List<SyncItem>): TeamSpeakImporter.Result =
withContext(Dispatchers.IO) { importer.importSelected(all, chosen) }
.also {
_bookmarks.value = bookmarkStore.all().toList()
identities.refresh()
}
// ---- sound packs ----
private val _soundPacks = MutableStateFlow(sounds.availablePacks())
val soundPacks: StateFlow<List<SoundPack>> = _soundPacks
val soundPack: SoundPack? get() = sounds.pack()
fun setCountryFlags(shown: Boolean) {
settings.countryFlags = shown
settings.save()
servers.value.forEach { it.scheduleTree(structure = false) }
}
fun setDescriptionPictures(shown: Boolean) {
settings.descriptionPictures = shown
settings.save()
}
fun setSoundPack(pack: SoundPack) {
sounds.setPack(pack)
settings.save()
}
/** Unpacks the packs the app ships with, when this version has not yet. */
fun installSoundPacks() {
if (!SoundPackInstaller(context).install()) return
sounds.reload()
_soundPacks.value = sounds.availablePacks()
}
// ---- the local client ----
fun hasMicPermission() =
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
/** Moves the microphone to [server]; needs RECORD_AUDIO granted. */
fun giveMicrophoneTo(server: ServerController) {
if (server.isConnected) sessions.giveMicrophoneTo(server.session)
}
/** Opens the microphone of the server holding it while push-to-talk is held. */
fun setPushToTalk(pressed: Boolean) = sessions.setPushToTalk(pressed)
private fun microphoneMoved() {
val owner = sessions.microphoneOwner()
for (server in _servers.value) {
server.micOwner = server.session == owner
server.refresh()
}
_micOwner.value = _servers.value.firstOrNull { it.micOwner }
updateLevels()
}
private val overlayPrefs = context.getSharedPreferences("overlay", Context.MODE_PRIVATE)
private val _pttOverlay = MutableStateFlow(overlayPrefs.getBoolean(OVERLAY_ENABLED, false))
/** Whether push to talk floats a button over other apps while this one is off screen. */
val pttOverlay: StateFlow<Boolean> = _pttOverlay
fun setPttOverlay(enabled: Boolean) {
overlayPrefs.edit().putBoolean(OVERLAY_ENABLED, enabled).apply()
_pttOverlay.value = enabled
}
/** Switches between push to talk and voice activation; returns the new mode. */
fun toggleInputMode(): Settings.InputMode {
settings.inputMode = if (settings.inputMode == Settings.InputMode.PUSH_TO_TALK)
Settings.InputMode.VOICE_ACTIVATION else Settings.InputMode.PUSH_TO_TALK
applySettings()
return settings.inputMode
}
/** Saves the settings and puts the voice and device ones into effect at once. */
fun applySettings() {
settings.save()
soundPlayer.setOutputDevice(settings.outputDevice)
try {
for (server in _servers.value) {
server.playback?.setMasterVolume(settings.outputVolume)
server.playback?.setOutputDevice(settings.outputDevice)
server.microphone?.apply(settings)
server.refresh()
server.scheduleTree(structure = false)
}
levels.apply()
} catch (e: RuntimeException) {
error(e.message ?: "Microphone unavailable")
}
}
/** Microphone preset and playback usage; the open lines move over at once. */
fun setRoute(route: AudioRoute) {
routePrefs.save(route)
_route.value = route
audio.setRoute(route)
}
/** Listens to the microphone for the level meter while [on]. */
fun watchLevel(on: Boolean) {
watchingLevel = on
updateLevels()
}
private fun updateLevels() {
val owner = _micOwner.value
levels.update(watchingLevel, owner?.microphone, owner?.isBusy == true, hasMicPermission())
}
// ---- notifications ----
/** Shows a private chat a notification was tapped for. */
fun requestChat(serverId: Int, clientId: Int) {
val server = server(serverId) ?: return
_current.value = server
server.requestChat(clientId)
}
/** Answers a private message from its notification. */
fun reply(serverId: Int, clientId: Int, text: String) {
server(serverId)?.reply(clientId, text) ?: notifier.clear(serverId, clientId)
}
private companion object {
const val DEFAULT_PORT = 9987
const val OVERLAY_ENABLED = "enabled"
fun parseAddress(address: String): Pair<String, Int> {
val host = address.substringBefore(':').trim()
val port = address.substringAfter(':', "").trim().toIntOrNull() ?: DEFAULT_PORT
return host to port
}
}
}

View File

@@ -0,0 +1,53 @@
package com.ts3client.android
import android.content.Context
import android.content.res.AssetManager
import com.ts3client.config.AppDirs
import java.io.File
/**
* Unpacks the sound packs bundled in the APK into the profile's `sound` folder, where
* [com.ts3client.sound.SoundNotifier] finds packs, as they are read as plain files.
* It happens once per installed version of the app; packs of other origins in the
* folder are left alone.
*/
class SoundPackInstaller(private val context: Context) {
private val root = File(AppDirs.profile(), ASSET_DIR)
private val marker = File(root, ".bundled")
/** Where a pack is unpacked first, outside [root] so it is never seen half written. */
private val staging = File(AppDirs.profile(), ".sound-staging")
/** @return whether the packs were (re)installed, so the list of them has to be read again */
fun install(): Boolean {
val version = context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime.toString()
if (marker.isFile && marker.readText() == version) return false
val assets = context.assets
for (pack in assets.list(ASSET_DIR).orEmpty()) {
staging.deleteRecursively()
copy(assets, "$ASSET_DIR/$pack", staging)
val target = File(root, pack)
target.deleteRecursively()
root.mkdirs()
check(staging.renameTo(target)) { "Cannot install sound pack $pack" }
}
root.mkdirs()
marker.writeText(version)
return true
}
private fun copy(assets: AssetManager, path: String, to: File) {
val children = assets.list(path).orEmpty()
if (children.isEmpty()) {
to.parentFile?.mkdirs()
assets.open(path).use { input -> to.outputStream().use { input.copyTo(it) } }
return
}
to.mkdirs()
for (child in children) copy(assets, "$path/$child", File(to, child))
}
private companion object {
const val ASSET_DIR = "sound"
}
}

View File

@@ -0,0 +1,31 @@
package com.ts3client.android
import android.content.Context
import android.os.Bundle
import android.speech.tts.TextToSpeech
import com.ts3client.sound.SoundPlayer
/**
* Adds the phone's text-to-speech to a wave player, for packs that speak their
* notifications. Sentences asked for before the engine is up are dropped: they
* would come out late, past what they announce.
*/
class SpeakingSoundPlayer(context: Context, private val waves: SoundPlayer) : SoundPlayer by waves {
@Volatile
private var ready = false
private val tts = TextToSpeech(context.applicationContext) { status -> ready = status == TextToSpeech.SUCCESS }
override fun canSpeak() = true
override fun say(text: String, volume: Double) {
if (!ready) return
val params = Bundle().apply { putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volume.toFloat()) }
tts.speak(text, TextToSpeech.QUEUE_ADD, params, null)
}
override fun shutdown() {
tts.shutdown()
waves.shutdown()
}
}

View File

@@ -0,0 +1,18 @@
package com.ts3client.android
import android.app.Application
import com.ts3client.config.AppDirs
class Ts3Application : Application() {
lateinit var sessions: SessionController
private set
override fun onCreate() {
super.onCreate()
// Before any store loads: there is no home directory to find them in.
AppDirs.init(filesDir, filesDir.toPath().resolve("chats"))
sessions = SessionController(this)
Thread(sessions::installSoundPacks, "sound-packs").start()
}
}

View File

@@ -0,0 +1,58 @@
package com.ts3client.android
import android.util.LruCache
import com.ts3client.config.Settings
import com.ts3client.net.PictureDownloads
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.ConcurrentHashMap
/** The pictures descriptions show, fetched by core's downloads and decoded while there is room. */
class WebPictures(cacheDir: File, private val settings: Settings) {
sealed interface State {
data object Loading : State
data object Broken : State
data class Shown(val picture: Picture) : State
}
private val downloads = PictureDownloads(File(cacheDir, "pictures"), false)
private val decoded = LruCache<String, Picture>(CACHED)
private val undecodable = ConcurrentHashMap.newKeySet<String>()
private val _version = MutableStateFlow(0)
/** Moves on whenever a download has come or failed. */
val version: StateFlow<Int> = _version
init {
downloads.addListener { _version.update { it + 1 } }
}
/** Whether pictures are shown at all, rather than links to them. */
val enabled: Boolean get() = settings.descriptionPictures
suspend fun state(url: String): State {
decoded.get(url)?.let { return State.Shown(it) }
if (url in undecodable) return State.Broken
val file = downloads.get(url) ?: return if (downloads.failed(url)) State.Broken else State.Loading
val picture = withContext(Dispatchers.IO) { decodePicture(file.readBytes(), MAX_PX) }
if (picture == null) {
undecodable += url
return State.Broken
}
decoded.put(url, picture)
return State.Shown(picture)
}
/** The picture's file as downloaded; null while it is not there. */
suspend fun data(url: String): ByteArray? = withContext(Dispatchers.IO) { downloads.get(url)?.readBytes() }
private companion object {
const val CACHED = 32
const val MAX_PX = 1024
}
}

View File

@@ -0,0 +1,105 @@
package com.ts3client.android.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.SessionController
import com.ts3client.android.SessionEvent
@Composable
fun App(vm: SessionController) {
val current by vm.current.collectAsStateWithLifecycle()
// Each server keeps its own screen state (chat open, conversation) while another is shown.
val serverStates = rememberSaveableStateHolder()
var connectOpen by rememberSaveable { mutableStateOf(false) }
val snackbar = remember { SnackbarHostState() }
var settingsOpen by rememberSaveable { mutableStateOf(false) }
var contactsOpen by rememberSaveable { mutableStateOf(false) }
var editingContact by rememberSaveable { mutableStateOf<String?>(null) }
// Set when a contact's page was opened from a client, so leaving it goes back there.
var contactDirect by rememberSaveable { mutableStateOf(false) }
var poke by remember { mutableStateOf<SessionEvent.Poke?>(null) }
LaunchedEffect(vm) {
vm.events.collect { event ->
when (event) {
is SessionEvent.Poke -> poke = event
is SessionEvent.Error -> snackbar.showSnackbar(event.message)
}
}
}
val context = LocalContext.current
val permissions = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
if (granted[Manifest.permission.RECORD_AUDIO] == true) vm.current.value?.let(vm::giveMicrophoneTo)
}
fun missing(vararg names: String) =
names.filter { context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
// The connection runs without either permission, just silent or without its notification.
val connectAsking: (() -> Unit) -> Unit = { connect ->
connect()
connectOpen = false
val ask = missing(Manifest.permission.RECORD_AUDIO, Manifest.permission.POST_NOTIFICATIONS)
if (ask.isNotEmpty()) permissions.launch(ask.toTypedArray())
}
val enableMicrophone: () -> Unit = {
if (missing(Manifest.permission.RECORD_AUDIO).isEmpty()) vm.current.value?.let(vm::giveMicrophoneTo)
else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO))
}
val openContacts: (String?) -> Unit = { uniqueId ->
contactsOpen = true
editingContact = uniqueId
contactDirect = uniqueId != null
}
CompositionLocalProvider(LocalIconTheme provides vm.icons, LocalWebPictures provides vm.pictures) {
val server = current
when {
contactsOpen -> ContactsScreen(vm, editingContact, onEdit = { uniqueId ->
if (uniqueId == null && contactDirect) contactsOpen = false
contactDirect = false
editingContact = uniqueId
}, onBack = { contactsOpen = false })
settingsOpen -> SettingsScreen(vm, onContacts = { openContacts(null) }, onBack = { settingsOpen = false })
server != null && !connectOpen -> {
serverStates.SaveableStateProvider(server.id) {
CompositionLocalProvider(LocalServerImages provides server.images) {
PictureViewerHost(server) {
ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone,
onConnectOther = { connectOpen = true })
}
}
}
}
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking,
onBack = if (current != null) ({ connectOpen = false }) else null)
}
}
poke?.let { p ->
AlertDialog(
onDismissRequest = { poke = null },
title = { Text("${p.from} poked you") },
text = if (p.message.isNotBlank()) ({ Text(p.message) }) else null,
confirmButton = { TextButton(onClick = { poke = null }) { Text("OK") } },
)
}
}

View File

@@ -0,0 +1,313 @@
package com.ts3client.android.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ts3client.android.ServerController
import com.ts3client.config.BanReasonPresets
import com.ts3client.net.ban.BanEntry
import com.ts3client.net.ban.BanPattern
import com.ts3client.net.ban.BanRule
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/** What the ban list shows instead of itself: a ban's editor, a new or an existing ban's. */
private sealed interface BanEdit {
data object New : BanEdit
data class Existing(val ban: BanEntry) : BanEdit
}
/** The server's ban list, as TeamSpeak's Ban List window: searchable, own bans apart, add, edit and remove. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BanListScreen(server: ServerController, presets: BanReasonPresets, onBack: () -> Unit) {
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
var bans by remember { mutableStateOf<List<BanEntry>?>(null) }
var status by remember { mutableStateOf("Loading…") }
/** Bumped to read the list again. */
var generation by remember { mutableIntStateOf(0) }
var query by rememberSaveable { mutableStateOf("") }
var onlyOwn by rememberSaveable { mutableStateOf(false) }
var shown by remember { mutableStateOf<BanEntry?>(null) }
var edit by remember { mutableStateOf<BanEdit?>(null) }
LaunchedEffect(generation) {
status = "Loading…"
server.banList()
.onSuccess {
bans = it
status = if (it.size == 1) "1 ban" else "${it.size} bans"
}
.onFailure {
bans = emptyList()
status = "Failed to request ban list: ${it.message}"
}
}
/** Reports a failed change, or re-reads the list to show what the server now holds. */
val change = { action: suspend () -> String?, failure: String ->
scope.launch {
val error = action()
if (error != null) snackbar.showSnackbar("$failure: $error")
generation++
}
Unit
}
edit?.let { e ->
val existing = (e as? BanEdit.Existing)?.ban
BanEditor(existing, presets, server.maxBanSeconds, onBack = { edit = null }) { rule ->
edit = null
if (existing == null) change({ server.addBan(rule) }, "Error adding ban")
else change({ server.replaceBan(existing.id, rule) }, "Error editing ban")
}
return
}
SubScreen(
"Ban list", onBack,
actions = {
if (server.canCreateBans) IconButton(onClick = { edit = BanEdit.New }) { PackIcon("ADD", "Add") }
IconButton(onClick = { generation++ }) { PackIcon("REFRESH", "Reload") }
},
snackbar = snackbar,
) { padding ->
val needle = query.trim().lowercase(Locale.ROOT)
val filtered = remember(bans, needle, onlyOwn) {
bans.orEmpty().filter { ban ->
(!onlyOwn || server.isOwnBan(ban)) && (needle.isEmpty() || searchText(ban).contains(needle))
}
}
Column(Modifier.padding(padding).imePadding().fillMaxSize()) {
Row(Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(query, { query = it }, Modifier.weight(1f), placeholder = { Text("Search") },
singleLine = true, leadingIcon = { PackIcon("SEARCH", null) })
FilterChip(onlyOwn, { onlyOwn = !onlyOwn }, label = { Text("Own") })
}
Text(status, Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
LazyColumn(Modifier.weight(1f)) {
items(filtered, key = { it.id }) { ban ->
ListItem(
headlineContent = { Text(ban.targets(), maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = {
Column {
if (ban.reason.isNotEmpty()) Text(ban.reason, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${ban.invokerName} · ${expiry(ban)}", style = MaterialTheme.typography.bodySmall)
}
},
modifier = Modifier.clickable { shown = ban },
)
HorizontalDivider()
}
}
}
}
shown?.let { ban ->
BanSheet(
ban,
onDismiss = { shown = null },
onEdit = if (server.canEditBan(ban)) ({ shown = null; edit = BanEdit.Existing(ban) }) else null,
onDelete = if (server.canDeleteBans(listOf(ban))) ({
shown = null
change({ server.deleteBans(listOf(ban.id)) }, "Error deleting ban")
}) else null,
)
}
}
private fun searchText(ban: BanEntry): String =
listOf(ban.targets(), ban.reason, ban.invokerName, date(ban.createdEpochSeconds), expiry(ban))
.joinToString("\n").lowercase(Locale.ROOT)
private val DATE: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
private fun date(epochSeconds: Long): String =
if (epochSeconds <= 0) "" else DATE.format(Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault()))
private fun expiry(ban: BanEntry): String = if (ban.isPermanent) "Never expires" else "Expires ${date(ban.expiresEpochSeconds())}"
/** Everything about one ban, and what may be done with it. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BanSheet(ban: BanEntry, onDismiss: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
var confirmDelete by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) {
Column(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Fact("Name", ban.name)
Fact("IP", ban.ip)
Fact("Unique ID", ban.uid)
Fact("Last nickname", ban.lastNickname)
Fact("myTeamSpeak ID", ban.mytsid)
Fact("Reason", ban.reason)
Fact("Creator", ban.invokerName)
Fact("Created", date(ban.createdEpochSeconds))
Fact("Expires", if (ban.isPermanent) "Never" else date(ban.expiresEpochSeconds()))
Fact("Enforced", ban.enforcements.toString())
}
onEdit?.let { Action("EDIT", "Edit", onClick = it) }
onDelete?.let { Action("DELETE", "Delete") { confirmDelete = true } }
}
}
if (confirmDelete && onDelete != null) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Delete ban?") },
text = { Text(ban.targets()) },
confirmButton = { TextButton(onClick = { confirmDelete = false; onDelete() }) { Text("Delete") } },
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun Fact(label: String, value: String) {
if (value.isEmpty()) return
Column {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
/** Adds a ban, or rewrites [existing]: what it matches, why, and for how long. */
@Composable
private fun BanEditor(
existing: BanEntry?,
presets: BanReasonPresets,
maxSeconds: Long,
onBack: () -> Unit,
onSave: (BanRule) -> Unit,
) {
val decoded = remember { BanPattern.decode(existing?.ip, existing?.name) }
var ip by remember { mutableStateOf(decoded.ip) }
var name by remember { mutableStateOf(decoded.name) }
var pattern by remember { mutableStateOf(if (existing == null) BanPattern.WILDCARD_IPV4 else decoded.pattern) }
var uid by remember { mutableStateOf(existing?.uid.orEmpty()) }
var mytsid by remember { mutableStateOf(existing?.mytsid.orEmpty()) }
var reason by remember { mutableStateOf(existing?.reason.orEmpty()) }
val duration = remember { BanDurationState(existing?.durationSeconds ?: DEFAULT_BAN_SECONDS) }
val rule = BanRule(pattern.ipToRegex(ip), pattern.nameToRegex(name), uid, mytsid, duration.seconds, reason)
val ipValid = BanPattern.isValidRegex(rule.ipRegex)
val nameValid = BanPattern.isValidRegex(rule.nameRegex)
val valid = rule.hasTarget() && ipValid && nameValid && duration.valid
SubScreen(
if (existing == null) "Add ban" else "Edit ban", onBack,
actions = { TextButton(onClick = { onSave(rule) }, enabled = valid) { Text("Save") } },
) { padding ->
Column(
Modifier.padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(ip, { ip = it }, Modifier.fillMaxWidth(), label = { Text("IP") }, singleLine = true,
isError = !ipValid)
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true,
isError = !nameValid)
PatternField(pattern) { pattern = it }
OutlinedTextField(uid, { uid = it }, Modifier.fillMaxWidth(), label = { Text("Unique ID") }, singleLine = true)
OutlinedTextField(mytsid, { mytsid = it }, Modifier.fillMaxWidth(), label = { Text("myTeamSpeak ID") },
singleLine = true)
ReasonField(reason, { reason = it }, presets)
BanDurationField(duration, maxSeconds)
}
}
}
/** How the IP and name are written: wildcards, fixed strings or regular expressions. */
@Composable
private fun PatternField(pattern: BanPattern, onSelect: (BanPattern) -> Unit) {
var open by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Interpret as", Modifier.weight(1f))
Box {
OutlinedButton(onClick = { open = true }) { Text(pattern.label()) }
DropdownMenu(open, { open = false }) {
for (p in BanPattern.entries) DropdownMenuItem({ Text(p.label()) }, onClick = { open = false; onSelect(p) })
}
}
}
}
/** The reason, with the saved presets to pick from and the reason typed to save as one. */
@Composable
private fun ReasonField(reason: String, onChange: (String) -> Unit, presets: BanReasonPresets) {
var open by remember { mutableStateOf(false) }
var saved by remember { mutableStateOf(presets.all().toList()) }
Box {
Row(verticalAlignment = Alignment.Top) {
LimitedField(reason, onChange, "Reason", BanRule.REASON_LIMIT, Modifier.weight(1f))
IconButton(onClick = { open = true }, Modifier.padding(top = 8.dp)) { Icon(Icons.Filled.ArrowDropDown, "Presets") }
}
DropdownMenu(open, { open = false }, Modifier.align(Alignment.TopEnd)) {
for (preset in saved) {
DropdownMenuItem({ Text(preset, maxLines = 1, overflow = TextOverflow.Ellipsis) },
onClick = { open = false; onChange(preset.take(BanRule.REASON_LIMIT)) },
trailingIcon = {
IconButton(onClick = {
presets.remove(preset)
presets.save()
saved = presets.all().toList()
}) { PackIcon("DELETE", "Remove preset") }
})
}
if (saved.isNotEmpty()) HorizontalDivider()
DropdownMenuItem({ Text("Save as preset") }, enabled = reason.isNotBlank(), onClick = {
open = false
presets.add(reason)
presets.save()
saved = presets.all().toList()
})
}
}
}

View File

@@ -0,0 +1,266 @@
package com.ts3client.android.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ts3client.android.ChannelItem
import com.ts3client.android.ClientItem
import com.ts3client.android.ServerTree
import com.ts3client.contacts.ContactType
import com.ts3client.gfx.IconKeys
import com.ts3client.net.ClientStatus
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
/** A channel's or a client's row. */
data class TreeRef(val channel: Boolean, val id: Int)
/** One line of the tree as the list shows it. */
private sealed interface TreeRow {
val ref: TreeRef
val key: String get() = (if (ref.channel) "c" else "u") + ref.id
data class Channel(val channel: ChannelItem, val depth: Int, val expanded: Boolean, val current: Boolean) : TreeRow {
override val ref get() = TreeRef(true, channel.id)
val expandable get() = channel.clients.isNotEmpty() || channel.children.isNotEmpty()
}
data class Client(val client: ClientItem, val depth: Int) : TreeRow {
override val ref get() = TreeRef(false, client.id)
}
}
/** The tree's channels and clients in reading order, leaving out what sits in folded channels. */
private fun flatten(tree: ServerTree, collapsed: Set<Int>, currentChannel: Int): List<TreeRow> {
val rows = ArrayList<TreeRow>()
fun add(channel: ChannelItem, depth: Int) {
val expanded = channel.id !in collapsed
rows += TreeRow.Channel(channel, depth, expanded, channel.id == currentChannel)
if (!expanded) return
channel.clients.forEach { rows += TreeRow.Client(it, depth + 1) }
channel.children.forEach { add(it, depth + 1) }
}
tree.channels.forEach { add(it, 0) }
return rows
}
@Composable
fun ChannelTree(
serverName: String,
tree: ServerTree,
collapsed: Set<Int>,
currentChannel: Int,
onToggle: (ChannelItem) -> Unit,
onChannel: (ChannelItem) -> Unit,
onClient: (ClientItem) -> Unit,
onServer: () -> Unit,
modifier: Modifier = Modifier,
/** A row to scroll to and flash, once the channels above it are expanded. */
reveal: TreeRef? = null,
onRevealed: () -> Unit = {},
/** The rows the find bar matched, and the one it is on, which a reveal does not flash. */
found: Set<TreeRef> = emptySet(),
foundCurrent: TreeRef? = null,
) {
val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) }
val listState = rememberLazyListState()
var flashing by remember { mutableStateOf<TreeRef?>(null) }
LaunchedEffect(reveal, rows) {
val ref = reveal ?: return@LaunchedEffect
val index = rows.indexOfFirst { it.ref == ref }
if (index < 0) return@LaunchedEffect
// Index 0 is the server's row. The row ends up in the middle of the list.
val middle = snapshotFlow { listState.layoutInfo.viewportSize.height }.first { it > 0 } / 2
listState.animateScrollToItem(index + 1, -middle)
if (ref != foundCurrent) flashing = ref
onRevealed()
}
LaunchedEffect(flashing) {
if (flashing != null) {
delay(1500)
flashing = null
}
}
LazyColumn(modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(vertical = 4.dp)) {
item(key = "server") {
RowLayout(depth = 0, onClick = onServer) {
PackIcon("SERVER_GREEN", null, size = ICON)
Text(serverName, Modifier.weight(1f), fontWeight = FontWeight.Bold, maxLines = 1,
overflow = TextOverflow.Ellipsis)
ServerIcon(tree.serverIconId, size = ICON)
}
}
items(rows, key = { it.key }) { row ->
val background = rowBackground(
when (row.ref) {
flashing -> FLASH
foundCurrent -> findCurrentColor()
in found -> findMatchColor()
else -> Color.Transparent
},
)
when (row) {
is TreeRow.Channel -> ChannelRow(row, background, onToggle, onChannel)
is TreeRow.Client -> ClientRow(row, tree.countryFlags, background, onClient)
}
}
}
}
private val ICON = 16.dp
private val INDENT = 14.dp
private val ROW = 26.dp
/** Amber, see-through so the row's text keeps its own colour and contrast. */
private val FLASH = Color(0x80FFB300)
/** Fades in fast and out slowly, so a flash is seen. */
@Composable
private fun rowBackground(target: Color): Color {
val color by animateColorAsState(target, tween(if (target == FLASH) 150 else 600), label = "row")
return color
}
@Composable
private fun RowLayout(
depth: Int,
onClick: (() -> Unit)?,
background: Color = Color.Transparent,
content: @Composable RowScope.() -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.background(background)
.height(ROW)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(start = 4.dp + INDENT * depth, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
ProvideTextStyle(MaterialTheme.typography.bodyMedium, content = { content() })
}
}
@Composable
private fun ChannelRow(row: TreeRow.Channel, background: Color, onToggle: (ChannelItem) -> Unit, onChannel: (ChannelItem) -> Unit) {
val channel = row.channel
val spacer = channel.spacer
RowLayout(row.depth, onClick = { onChannel(channel) }, background = background) {
Box(
Modifier.fillMaxHeight().width(ICON + 4.dp)
.then(if (row.expandable) Modifier.clickable { onToggle(channel) } else Modifier),
contentAlignment = Alignment.Center,
) {
if (row.expandable) {
Icon(Icons.Filled.KeyboardArrowDown, if (row.expanded) "Collapse" else "Expand",
Modifier.size(ICON + 2.dp).rotate(if (row.expanded) 0f else -90f))
}
}
if (spacer != null) {
SpacerLabel(spacer.align(), spacer.caption().orEmpty(), Modifier.weight(1f))
} else {
PackIcon(IconKeys.channel(channel.locked, channel.full, channel.subscribed).asList(), null, size = ICON)
Text(
channel.name,
Modifier.weight(1f),
color = if (row.current) MaterialTheme.colorScheme.primary else Color.Unspecified,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
ServerIcon(channel.iconId, size = ICON)
}
}
}
/** A spacer's caption, placed as its tag asks; a repeated one of dashes or the like becomes a rule. */
@Composable
private fun SpacerLabel(align: Char, caption: String, modifier: Modifier) {
if (align == '*') {
if (caption.isNotEmpty() && caption.all { it in "-_=.~" }) {
HorizontalDivider(modifier)
} else {
Text(caption.repeat(200 / caption.length.coerceAtLeast(1)), modifier, maxLines = 1, overflow = TextOverflow.Clip)
}
return
}
val textAlign = when (align) {
'c' -> TextAlign.Center
'r' -> TextAlign.End
else -> TextAlign.Start
}
Text(caption, modifier, textAlign = textAlign, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun ClientRow(row: TreeRow.Client, countryFlags: Boolean, background: Color, onClient: (ClientItem) -> Unit) {
val client = row.client
RowLayout(row.depth, onClick = { onClient(client) }, background = background) {
Spacer(Modifier.width(ICON + 4.dp))
StatusIcon(client.status)
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
client.name,
fontWeight = if (client.self) FontWeight.Bold else FontWeight.Normal,
color = when (client.highlight) {
ContactType.FRIEND -> StatusColors.friend
ContactType.BLOCKED -> StatusColors.blocked
else -> Color.Unspecified
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (client.awayMessage.isNotEmpty()) {
Text("[${client.awayMessage}]", Modifier.weight(1f, fill = false),
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
for (id in client.groupIconIds) ServerIcon(id, size = ICON)
// The country's flag comes last, at the row's edge.
if (countryFlags) CountryFlag(client.country)
}
}
@Composable
fun StatusIcon(status: ClientStatus, modifier: Modifier = Modifier) {
PackIcon(IconKeys.client(status), status.name, modifier, size = ICON)
}

View File

@@ -0,0 +1,179 @@
package com.ts3client.android.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Badge
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ts3client.android.ChatKey
import com.ts3client.android.ChatLine
import com.ts3client.android.ChatMatch
import com.ts3client.android.Chats
import com.ts3client.android.Conversation
import com.ts3client.text.TextQuery
import kotlinx.coroutines.flow.first
@Composable
fun ChatPane(
chats: Chats,
selected: Conversation,
canSend: Boolean,
onSelect: (ChatKey) -> Unit,
onClose: (ChatKey.Private) -> Unit,
onSend: (String) -> Unit,
onLink: (String) -> Unit,
modifier: Modifier = Modifier,
/** The lines to show, when not all of [selected]'s. */
lines: List<ChatLine> = selected.lines,
find: TextQuery = TextQuery.EMPTY,
/** The match the find bar is on. */
current: ChatMatch? = null,
/** A line to scroll to. */
reveal: Long? = null,
onRevealed: () -> Unit = {},
) {
Column(modifier) {
LazyRow(
contentPadding = PaddingValues(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(chats.all, key = { it.key.toString() }) { conversation ->
ConversationChip(conversation, conversation.key == selected.key, onSelect, onClose)
}
}
// Each conversation opens at its newest line.
key(selected.key) { Lines(lines, onLink, Modifier.weight(1f), find, current, reveal, onRevealed) }
// One unsent message per conversation, kept while another one is on screen.
val drafts = rememberSaveable(saver = DraftsSaver) { mutableStateMapOf<String, String>() }
val draftKey = selected.key.toString()
val draft = drafts[draftKey].orEmpty()
val send = {
if (draft.isNotBlank()) {
onSend(draft.trim())
drafts.remove(draftKey)
}
}
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
draft, { drafts[draftKey] = it },
Modifier.weight(1f).onPreviewKeyEvent { e ->
// A hardware keyboard's Enter sends, as on the desktop; Shift+Enter starts a new line.
val enter = e.key == Key.Enter && !e.isShiftPressed
if (enter && e.type == KeyEventType.KeyDown) send()
enter
},
placeholder = { Text("Message ${selected.title}", maxLines = 1, overflow = TextOverflow.Ellipsis) },
enabled = canSend,
maxLines = 4,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { send() }),
)
IconButton(onClick = send, enabled = canSend && draft.isNotBlank()) {
Icon(Icons.AutoMirrored.Filled.Send, "Send")
}
}
}
}
private val DraftsSaver = Saver<SnapshotStateMap<String, String>, HashMap<String, String>>(
save = { HashMap(it) },
restore = { mutableStateMapOf<String, String>().apply { putAll(it) } },
)
@Composable
private fun ConversationChip(
conversation: Conversation,
selected: Boolean,
onSelect: (ChatKey) -> Unit,
onClose: (ChatKey.Private) -> Unit,
) {
val key = conversation.key
FilterChip(
selected = selected,
onClick = { onSelect(key) },
label = { Text(conversation.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
leadingIcon = if (conversation.unread) ({ Badge() }) else null,
trailingIcon = if (key is ChatKey.Private) ({
Icon(Icons.Filled.Close, "Close", Modifier.size(18.dp).clickable { onClose(key) })
}) else null,
)
}
/** The conversation's lines, following new ones while the newest is in view. */
@Composable
private fun Lines(
lines: List<ChatLine>,
onLink: (String) -> Unit,
modifier: Modifier,
find: TextQuery,
current: ChatMatch?,
reveal: Long?,
onRevealed: () -> Unit,
) {
val listState = rememberLazyListState(initialFirstVisibleItemIndex = (lines.size - 1).coerceAtLeast(0))
val link by rememberUpdatedState(onLink)
LaunchedEffect(lines.lastOrNull()?.id) {
val info = listState.layoutInfo
val atEnd = info.visibleItemsInfo.lastOrNull()?.index?.let { it >= info.totalItemsCount - 2 } ?: true
if (atEnd && lines.isNotEmpty()) listState.scrollToItem(lines.size - 1)
}
LaunchedEffect(reveal, lines) {
val id = reveal ?: return@LaunchedEffect
val index = lines.indexOfFirst { it.id == id }
if (index < 0) return@LaunchedEffect
val info = listState.layoutInfo
if (info.visibleItemsInfo.none { it.index == index && it.offset >= 0 && it.offset + it.size <= info.viewportEndOffset }) {
// The line's top ends up in the middle of the list.
val middle = snapshotFlow { listState.layoutInfo.viewportSize.height }.first { it > 0 } / 2
listState.animateScrollToItem(index, -middle)
}
onRevealed()
}
LazyColumn(modifier.fillMaxWidth(), state = listState, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
items(lines, key = { it.id }) { line ->
RichText(line.html, { link(it) }, Modifier.padding(vertical = 2.dp), MaterialTheme.typography.bodyMedium,
find, if (current?.line == line.id) current.nth else -1)
}
}
}

View File

@@ -0,0 +1,307 @@
package com.ts3client.android.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.WebPictures
import com.ts3client.text.BBCode
import com.ts3client.text.ChatHtml
import com.ts3client.text.TextQuery
/**
* HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers
* and its pictures fetched from the web: Android's HTML parser shows neither, so the text is
* split at them.
*/
@Composable
fun RichText(
html: String,
onLink: (String) -> Unit,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
/** Matches to highlight: [current] is the index of the one the find bar is on, if it is here. */
find: TextQuery = TextQuery.EMPTY,
current: Int = -1,
) {
val link by rememberUpdatedState(onLink)
val color = linkColor()
val parsed = remember(html, color) { split(html) { chatText(it, color) { href -> link(href) } } }
val match = findMatchColor()
val currentMatch = findCurrentColor()
val pieces = remember(parsed, find, current, match, currentMatch) {
if (find.isEmpty) parsed else highlight(parsed, find, current, match, currentMatch)
}
Column(modifier) {
for (piece in pieces) {
when (piece) {
Piece.Rule -> HorizontalDivider(Modifier.padding(vertical = 6.dp))
is Piece.Image -> WebPicture(piece, style) { link(it) }
// As wide as there is room, for aligned paragraphs to have somewhere to go.
is Piece.Text -> if (piece.text.isNotEmpty()) Text(piece.text, Modifier.fillMaxWidth(), style = style)
}
}
}
}
/** [pieces] with [find]'s matches behind [match], and the [current]th of them, counting across pieces, behind [currentMatch]. */
private fun highlight(pieces: List<Piece>, find: TextQuery, current: Int, match: Color, currentMatch: Color): List<Piece> {
var n = 0
return pieces.map { piece ->
if (piece !is Piece.Text) return@map piece
val m = find.matcher(piece.text.text) ?: return@map piece
val text = AnnotatedString.Builder(piece.text)
while (m.find()) {
if (m.end() == m.start()) continue
text.addStyle(SpanStyle(background = if (n == current) currentMatch else match), m.start(), m.end())
n++
}
Piece.Text(text.toAnnotatedString())
}
}
/** The web pictures descriptions show. */
val LocalWebPictures = staticCompositionLocalOf<WebPictures?> { null }
private sealed interface Piece {
data class Text(val text: AnnotatedString) : Piece
data object Rule : Piece
/** @param href where the picture links to, if it sits in a link */
data class Image(val url: String, val href: String?, val align: Alignment.Horizontal) : Piece
}
/**
* [html] cut at its rules and pictures. Each piece of text is closed where it is cut and the
* next reopens what it was in, so an aligned block or a link carries on past a picture.
*/
private fun split(html: String, text: (String) -> AnnotatedString): List<Piece> {
val pieces = ArrayList<Piece>()
val divs = ArrayList<String>()
var anchor: String? = null
var href: String? = null
val current = StringBuilder()
fun cut() {
if (anchor != null) current.append("</a>")
repeat(divs.size) { current.append("</div>") }
pieces += Piece.Text(text(current.toString()))
current.setLength(0)
divs.forEach { current.append(it) }
anchor?.let { current.append(it) }
}
var last = 0
for (m in CUT_TAG.findAll(html)) {
current.append(html, last, m.range.first)
last = m.range.last + 1
val tag = m.value
when {
tag.startsWith("<hr", ignoreCase = true) -> {
cut()
pieces += Piece.Rule
}
tag.startsWith("<img", ignoreCase = true) -> {
cut()
pieces += Piece.Image(unescape(m.groupValues[1]), href?.let(::unescape), alignment(divs))
}
tag.startsWith("<div", ignoreCase = true) -> {
divs += tag
current.append(tag)
}
tag.equals("</div>", ignoreCase = true) -> {
divs.removeLastOrNull()
current.append(tag)
}
tag.startsWith("<a", ignoreCase = true) -> {
anchor = tag
href = m.groupValues[2]
current.append(tag)
}
else -> {
anchor = null
href = null
current.append(tag)
}
}
}
current.append(html, last, html.length)
pieces += Piece.Text(text(current.toString()))
return pieces
}
/** Where the innermost aligned block puts what is in it. */
private fun alignment(divs: List<String>): Alignment.Horizontal {
val align = divs.asReversed().firstNotNullOfOrNull { ALIGN.find(it)?.groupValues?.get(1) }
return when (align) {
"center" -> Alignment.CenterHorizontally
"right" -> Alignment.End
else -> Alignment.Start
}
}
private fun unescape(s: String) =
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&amp;", "&")
/**
* A picture at its own size, a pixel to a dp as TeamSpeak shows it, narrowed to fit. With
* pictures turned off, or when one cannot be had, its address as a link behind a picture icon.
*/
@Composable
private fun WebPicture(image: Piece.Image, style: TextStyle, onLink: (String) -> Unit) {
val pictures = LocalWebPictures.current
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalAlignment = image.align) {
if (pictures == null || !pictures.enabled) {
PictureLink("ICONVIEWER", image, style, onLink)
return@Column
}
val version by pictures.version.collectAsStateWithLifecycle()
val state by produceState<WebPictures.State>(WebPictures.State.Loading, image.url, version) {
value = pictures.state(image.url)
}
when (val shown = state) {
WebPictures.State.Loading -> PackIcon("LOADING_IMAGE", "Loading picture", size = 16.dp)
WebPictures.State.Broken -> PictureLink("BROKEN_IMAGE", image, style, onLink)
is WebPictures.State.Shown -> {
val picture = shown.picture
val viewer = LocalPictureViewer.current
Image(
rememberPicturePainter(picture),
null,
Modifier
.widthIn(max = picture.width.dp)
.aspectRatio(picture.width.toFloat() / picture.height.coerceAtLeast(1))
.then(if (viewer != null) Modifier.clickable {
val openLink = image.href?.let { href -> { onLink(href) } }
viewer(ViewedPicture(pictureName(image.url), image.url, openLink) { pictures.data(image.url) })
} else if (image.href != null) Modifier.clickable { onLink(image.href) } else Modifier),
)
}
}
}
}
/** The picture's address as a link behind [iconKey], then the link it sat in, if any, so neither is lost. */
@Composable
private fun PictureLink(iconKey: String, image: Piece.Image, style: TextStyle, onLink: (String) -> Unit) {
val color = linkColor()
val text = remember(image, color) {
val links = listOfNotNull(image.url, image.href?.takeIf { it != image.url })
chatText(links.joinToString(" ") { "<a href=\"${BBCode.escape(it)}\">${BBCode.escape(BBCode.shortAddress(it))}</a>" }, color, onLink)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
PackIcon(iconKey, "Picture", size = 16.dp)
Text(text, style = style)
}
}
/**
* HTML without rules as styled text. Android's parser knows no stylesheets, tables,
* numbered lists, font sizes or `align`, so the classes [ChatHtml] marks lines with become
* inline colours, table rows lines of their cells, numbered items numbered lines, sizes
* `<big>`/`<small>`, and alignment the style it does know.
*/
fun chatText(html: String, linkColor: Color, onLink: (String) -> Unit): AnnotatedString {
val styled = sizes(numbered(CLASS.replace(html) { m ->
CLASS_COLORS[m.groupValues[1]]?.let { "style=\"color:$it\"" } ?: ""
}))
.replace(ALIGN) { m -> "style=\"text-align:${ALIGNS.getValue(m.groupValues[1])}\"" }
.replace(CELL_END, " ")
.replace(ROW_END, "<br>")
val text = AnnotatedString.fromHtml(
styled,
TextLinkStyles(SpanStyle(color = linkColor, fontWeight = FontWeight.SemiBold)),
) { link -> (link as? LinkAnnotation.Url)?.let { onLink(it.url) } }
// Block elements leave line breaks around the text.
val start = text.text.indexOfFirst { !it.isWhitespace() }.coerceAtLeast(0)
val end = text.text.indexOfLast { !it.isWhitespace() } + 1
return if (end <= start) AnnotatedString("") else text.subSequence(start, end)
}
/** Numbers the items of each `<ol>`, innermost first. */
private fun numbered(html: String): String {
var out = html
while (true) {
val list = INNER_OL.find(out) ?: return out
val letters = list.groupValues[1].isNotEmpty()
var n = 0
val items = LI.replace(list.groupValues[2]) { m ->
if (m.value.startsWith("</")) "" else {
n++
"<br>" + (if (letters) ('a' + (n - 1) % 26).toString() else n.toString()) + ". "
}
}
out = out.replaceRange(list.range, "<div>$items</div>")
}
}
/**
* Turns each font-size span into `<big>`s or `<small>` (or a plain span), closing it alike.
* Sizes are TeamSpeak's, around its 11 px text.
*/
private fun sizes(html: String): String {
val closes = ArrayDeque<String>()
return SPAN.replace(html) { m ->
if (m.value.startsWith("</")) {
closes.removeLastOrNull() ?: m.value
} else {
val px = m.groupValues[1].toIntOrNull()
val tags = when {
px == null -> listOf("span")
px >= BIGGER_PX -> listOf("big", "big")
px >= BIG_PX -> listOf("big")
px <= SMALL_PX -> listOf("small")
else -> listOf("span")
}
closes.addLast(tags.reversed().joinToString("") { "</$it>" })
if (px == null) m.value else tags.joinToString("") { "<$it>" }
}
}
}
private const val BIGGER_PX = 18
private const val BIG_PX = 13
private const val SMALL_PX = 9
private val INNER_OL = Regex("<ol( type=\"a\")?>((?:(?!<ol[ >]).)*?)</ol>", RegexOption.DOT_MATCHES_ALL)
private val LI = Regex("</?li>")
private val SPAN = Regex("<span style=\"font-size:(\\d+)px\">|<span[^>]*>|</span>")
private val ALIGNS = mapOf("left" to "start", "justify" to "start", "center" to "center", "right" to "end")
/** Where the text is cut, and what it has to carry across: aligned blocks and links. */
private val CUT_TAG = Regex(
"<hr\\s*/?>|<img src=\"([^\"]*)\">|<div[^>]*>|</div>|<a [^>]*href=\"([^\"]*)\"[^>]*>|</a>",
RegexOption.IGNORE_CASE,
)
private val CLASS = Regex("class=\"([a-z]+)\"")
private val ALIGN = Regex("align=\"(left|center|right|justify)\"")
private val CELL_END = Regex("</t[dh]>")
private val ROW_END = Regex("</tr>")
private val CLASS_COLORS = mapOf(
ChatHtml.MUTED_CLASS to "#8c8c8c",
ChatHtml.EVENT_CLASS to "#7a8fa6",
)

View File

@@ -0,0 +1,248 @@
package com.ts3client.android.ui
import android.graphics.BitmapFactory
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.Icon
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.IdentityInfo
import com.ts3client.android.SessionController
import com.ts3client.config.Bookmark
import com.ts3client.net.IconRepository
/** Where to connect to: an address typed in, or a bookmark. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectScreen(
vm: SessionController,
snackbar: SnackbarHostState,
onSettings: () -> Unit,
onConnect: (() -> Unit) -> Unit,
/** Back to the servers already connected; null when there are none. */
onBack: (() -> Unit)?,
) {
val lastEnd by vm.lastEnd.collectAsStateWithLifecycle()
val bookmarks by vm.bookmarks.collectAsStateWithLifecycle()
val identities by vm.identities.all.collectAsStateWithLifecycle()
var address by rememberSaveable { mutableStateOf(vm.lastAddress) }
var identityId by rememberSaveable { mutableStateOf("") }
var nickname by rememberSaveable { mutableStateOf(vm.nicknameFor("")) }
var password by rememberSaveable { mutableStateOf("") }
var naming by rememberSaveable { mutableStateOf(false) }
var choosingFor by remember { mutableStateOf<Bookmark?>(null) }
if (onBack != null) BackHandler(onBack = onBack)
Scaffold(
topBar = {
TopAppBar(
title = { Text("TeamSpeak") },
navigationIcon = {
if (onBack != null) IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
},
actions = {
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
},
)
},
snackbarHost = { SnackbarHost(snackbar) },
) { padding ->
LazyColumn(
Modifier.padding(padding).imePadding(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(address, { address = it }, Modifier.fillMaxWidth(), label = { Text("Server address") },
singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth(), label = { Text("Nickname") },
singleLine = true)
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(),
label = { Text("Server password") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
IdentityField(identities, identityId) {
identityId = it
nickname = vm.nicknameFor(it)
}
lastEnd?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { onConnect { vm.connect(address.trim(), nickname.trim(), password, identityId) } },
enabled = address.isNotBlank() && nickname.isNotBlank(),
) { Text("Connect") }
TextButton(onClick = { naming = true }, enabled = address.isNotBlank()) { Text("Add bookmark") }
}
}
}
if (bookmarks.isNotEmpty()) {
item {
Text("Bookmarks", Modifier.padding(top = 16.dp), style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary)
}
}
items(bookmarks) { bookmark ->
BookmarkRow(bookmark, identities, { onConnect { vm.connect(bookmark) } },
onChooseIdentity = { choosingFor = bookmark }, onRemove = { vm.removeBookmark(bookmark) })
}
}
}
if (naming) {
TextPrompt(
title = "Add bookmark",
label = "Name",
confirm = "Add",
initial = address,
onDismiss = { naming = false },
onConfirm = {
naming = false
vm.addBookmark(it, address.trim(), nickname.trim(), password, identityId)
},
)
}
choosingFor?.let { bookmark ->
IdentityDialog(identities, bookmark.identityId, onDismiss = { choosingFor = null }) {
choosingFor = null
vm.setBookmarkIdentity(bookmark, it)
}
}
}
/** The identities to connect with, the default one first as an empty id. */
private fun identityChoices(identities: List<IdentityInfo>): List<Pair<String, String>> {
val default = identities.firstOrNull { it.isDefault }?.name
return listOf("" to (default?.let { "Default ($it)" } ?: "Default")) + identities.map { it.id to it.name }
}
/** An id no longer among the identities connects with the default, and shows as such. */
private fun shownId(identities: List<IdentityInfo>, id: String) = id.takeIf { i -> identities.any { it.id == i } } ?: ""
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun IdentityField(identities: List<IdentityInfo>, selected: String, onSelect: (String) -> Unit) {
val choices = identityChoices(identities)
val shown = shownId(identities, selected)
var open by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(open, { open = it }) {
OutlinedTextField(
choices.first { it.first == shown }.second, {}, readOnly = true, singleLine = true,
label = { Text("Identity") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(open) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(open, { open = false }) {
for ((id, name) in choices) {
DropdownMenuItem({ Text(name) }, onClick = {
open = false
onSelect(id)
})
}
}
}
}
@Composable
private fun IdentityDialog(identities: List<IdentityInfo>, selected: String, onDismiss: () -> Unit, onSelect: (String) -> Unit) {
val shown = shownId(identities, selected)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Connect with") },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
for ((id, name) in identityChoices(identities)) Choice(name, id == shown) { onSelect(id) }
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
/** The server's icon as last seen there, else the pack's bookmark icon. */
@Composable
private fun BookmarkIcon(iconId: Long) {
val cached = remember(iconId) {
IconRepository.cached(iconId)?.let { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }
}
if (cached != null) Image(cached, null, Modifier.size(24.dp))
else PackIcon("BOOKMARK_MANAGER", null)
}
@Composable
private fun BookmarkRow(
bookmark: Bookmark,
identities: List<IdentityInfo>,
onConnect: () -> Unit,
onChooseIdentity: () -> Unit,
onRemove: () -> Unit,
) {
val identity = identities.firstOrNull { it.id == bookmark.identityId }
var menu by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text(bookmark.displayName()) },
supportingContent = {
Text("${bookmark.address}:${bookmark.port}" + (identity?.let { " · ${it.name}" } ?: ""))
},
leadingContent = { BookmarkIcon(bookmark.iconId) },
trailingContent = {
Box {
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "Bookmark options") }
DropdownMenu(menu, { menu = false }) {
DropdownMenuItem({ Text("Identity") }, onClick = { menu = false; onChooseIdentity() })
DropdownMenuItem({ Text("Remove") }, onClick = { menu = false; onRemove() })
}
}
},
modifier = Modifier.clickable(onClick = onConnect),
)
}

View File

@@ -0,0 +1,271 @@
package com.ts3client.android.ui
import android.content.ClipData
import android.content.ClipboardManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.SessionController
import com.ts3client.contacts.Contact
import com.ts3client.contacts.ContactDefaults
import com.ts3client.contacts.ContactType
import com.ts3client.contacts.NicknameDisplay
import kotlinx.coroutines.launch
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
private val SEEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
private val DISPLAYS = listOf(
NicknameDisplay.ONLY_NICKNAME to "Nickname",
NicknameDisplay.ONLY_CUSTOM to "Custom name",
NicknameDisplay.BOTH to "Both",
)
private val GROUPS = listOf(
ContactType.FRIEND to "Friends",
ContactType.BLOCKED to "Blocked",
ContactType.NEUTRAL to "Neutral",
)
/**
* The contact list, grouped as TeamSpeak's Contacts window groups it, with each
* contact's own page and the defaults new contacts start out with.
*
* @param editing the unique id of the contact whose page is open, if any
*/
@Composable
fun ContactsScreen(vm: SessionController, editing: String?, onEdit: (String?) -> Unit, onBack: () -> Unit) {
var defaults by rememberSaveable { mutableStateOf(false) }
when {
editing != null -> return ContactScreen(vm, editing, onBack = { onEdit(null) })
defaults -> return ContactDefaultsScreen(vm, onBack = { defaults = false })
}
val contacts by vm.contacts.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var menu by remember { mutableStateOf(false) }
var imported by remember { mutableStateOf<String?>(null) }
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) scope.launch {
imported = runCatching { vm.importContacts(uri) }.fold(
{ (found, added) ->
if (found == 0) "That file holds no contacts."
else "Imported $found contacts, $added of them new."
},
{ "That file is not a TeamSpeak 3 settings.db." },
)
}
}
SubScreen("Contacts", onBack, actions = {
Box {
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "More") }
DropdownMenu(menu, { menu = false }) {
DropdownMenuItem({ Text("Defaults for new contacts") }, onClick = { menu = false; defaults = true })
DropdownMenuItem({ Text("Import from TeamSpeak 3") }, onClick = {
menu = false
importer.launch(arrayOf("*/*"))
})
}
}
}) { padding ->
LazyColumn(Modifier.padding(padding)) {
imported?.let { item { Hint(it) } }
if (contacts.isEmpty()) {
item {
Hint("No contacts yet. Tap a client on a server to add them as a friend or block them, " +
"or import the contacts of TeamSpeak 3 from its settings.db.")
}
}
for ((type, title) in GROUPS) {
val group = contacts.filter { it.type == type }.sortedBy { label(it).lowercase() }
if (group.isEmpty()) continue
item(key = type) { Section(title) }
items(group, key = { it.uniqueId }) { contact ->
ListItem(
headlineContent = { Text(label(contact), color = typeColor(contact.type)) },
supportingContent = { Text(lastSeen(contact)) },
modifier = Modifier.clickable { onEdit(contact.uniqueId) },
)
}
}
}
}
}
/** One contact's settings; each is stored as it is changed, the names once the page is left. */
@Composable
private fun ContactScreen(vm: SessionController, uniqueId: String, onBack: () -> Unit) {
val contacts by vm.contacts.collectAsStateWithLifecycle()
val contact = contacts.firstOrNull { it.uniqueId == uniqueId }
if (contact == null) {
LaunchedEffect(Unit) { onBack() }
return
}
// Starts from the stored entry, which may have been seen again since this page opened.
fun edit(change: Contact.() -> Unit) {
vm.contact(uniqueId)?.let { vm.saveContact(it.apply(change)) }
}
var customName by rememberSaveable(uniqueId) { mutableStateOf(contact.nickname) }
var phoneticName by rememberSaveable(uniqueId) { mutableStateOf(contact.phoneticNickname) }
var removing by remember { mutableStateOf(false) }
val leave = {
if (customName.trim() != contact.nickname || phoneticName.trim() != contact.phoneticNickname) {
edit {
nickname = customName.trim()
phoneticNickname = phoneticName.trim()
}
}
onBack()
}
SubScreen(label(contact), leave, actions = {
IconButton(onClick = { removing = true }) { PackIcon(if (contact.type == ContactType.BLOCKED) "REMOVE_FOE" else "REMOVE_FRIEND", "Remove from contacts") }
}) { padding ->
Column(Modifier.padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) {
OutlinedTextField(customName, { customName = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp),
label = { Text("Custom nickname") }, singleLine = true)
OutlinedTextField(phoneticName, { phoneticName = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
label = { Text("Phonetic nickname") }, singleLine = true)
Section("Contact")
Segments(GROUPS.map { (type, _) -> type to type.label() }, contact.type) { edit { type = it } }
Section("Show as")
Segments(DISPLAYS, contact.nicknameDisplay) { edit { nicknameDisplay = it } }
Section("Ignore")
Toggle("Mute", contact.automute) { edit { automute = it } }
Toggle("Ignore server and channel chat", contact.ignorePublicMessages) { edit { ignorePublicMessages = it } }
Toggle("Ignore private chat", contact.ignorePrivateMessages) { edit { ignorePrivateMessages = it } }
Toggle("Ignore pokes", contact.ignorePokes) { edit { ignorePokes = it } }
Toggle("Hide avatar", contact.ignoreAvatar) { edit { ignoreAvatar = it } }
Toggle("Hide away message", contact.ignoreAwayMessage) { edit { ignoreAwayMessage = it } }
Toggle("Deny whisper", !contact.whisperAllowed) { edit { whisperAllowed = !it } }
Section("Playback")
val volume = if (contact.hasVolumeModifier) contact.volumeModifier.toFloat() else 0f
Level("Volume", volume, -30f..20f, ::formatVolume) { db ->
val rounded = db.roundToInt().toDouble()
edit {
hasVolumeModifier = rounded != 0.0
volumeModifier = rounded
}
}
Section("Details")
val context = LocalContext.current
LinkRow("Unique ID", contact.uniqueId) {
// The system confirms the copy itself.
context.getSystemService(ClipboardManager::class.java)
.setPrimaryClip(ClipData.newPlainText("Unique ID", contact.uniqueId))
}
ListItem(headlineContent = { Text(lastSeen(contact)) })
}
}
if (removing) {
AlertDialog(
onDismissRequest = { removing = false },
title = { Text("Remove ${label(contact)}?") },
text = { Text("Their settings here are forgotten; they show as anyone else again.") },
confirmButton = {
TextButton(onClick = {
removing = false
vm.removeContact(uniqueId)
onBack()
}) { Text("Remove") }
},
dismissButton = { TextButton(onClick = { removing = false }) { Text("Cancel") } },
)
}
}
/** What a client starts out with when added as a friend, blocked or neutral. */
@Composable
private fun ContactDefaultsScreen(vm: SessionController, onBack: () -> Unit) {
val defaults = vm.settings.contactDefaults
var tab by rememberSaveable { mutableStateOf(ContactType.FRIEND) }
var entry by remember(tab) { mutableStateOf(ContactDefaults.Entry().apply { copyFrom(defaults.of(tab)) }) }
fun edit(change: ContactDefaults.Entry.() -> Unit) {
val changed = ContactDefaults.Entry().apply { copyFrom(entry); change() }
defaults.of(tab).copyFrom(changed)
vm.settings.save()
entry = changed
}
SubScreen("Defaults for new contacts", onBack) { padding ->
Column(Modifier.padding(padding)) {
PrimaryTabRow(selectedTabIndex = GROUPS.indexOfFirst { it.first == tab }) {
for ((type, title) in GROUPS) Tab(tab == type, onClick = { tab = type }, text = { Text(title) })
}
Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
Section("Show as")
Segments(DISPLAYS, entry.nicknameDisplay) { edit { nicknameDisplay = it } }
Section("Ignore")
Toggle("Mute", entry.automute) { edit { automute = it } }
Toggle("Ignore server and channel chat", entry.ignorePublicMessages) { edit { ignorePublicMessages = it } }
Toggle("Ignore private chat", entry.ignorePrivateMessages) { edit { ignorePrivateMessages = it } }
Toggle("Ignore pokes", entry.ignorePokes) { edit { ignorePokes = it } }
Toggle("Hide avatar", entry.ignoreAvatar) { edit { ignoreAvatar = it } }
Toggle("Hide away message", entry.ignoreAwayMessage) { edit { ignoreAwayMessage = it } }
Toggle("Deny whisper", entry.ignoreWhisper) { edit { ignoreWhisper = it } }
}
}
}
}
private fun label(contact: Contact) = contact.nickname.ifBlank { contact.uniqueId }
private fun lastSeen(contact: Contact): String {
val seen = contact.lastSeen ?: return "Never seen"
val where = contact.lastSeenServerName.ifBlank { contact.lastSeenServerAddress }
return "Last seen ${SEEN.format(seen)}" + if (where.isNotBlank()) " on $where" else ""
}
private fun formatVolume(db: Float): String {
val rounded = db.roundToInt()
return if (rounded == 0) "Unchanged" else "${if (rounded > 0) "+" else ""}$rounded dB"
}
private fun typeColor(type: ContactType): Color = when (type) {
ContactType.FRIEND -> StatusColors.friend
ContactType.BLOCKED -> StatusColors.blocked
ContactType.NEUTRAL -> Color.Unspecified
}

View File

@@ -0,0 +1,137 @@
package com.ts3client.android.ui
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.ts3client.net.Details
import com.ts3client.text.BBCode
/**
* The facts of the server, a channel or a client, then its description, for the top of its sheet.
* The minor facts wait behind "more…".
*/
@Composable
fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifier = Modifier) {
var all by rememberSaveable(details.title()) { mutableStateOf(false) }
val shown = if (all) details.fields() else details.fields().filterNot { it.minor() }
val more = !all && shown.size < details.fields().size
Column(modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (shown.isNotEmpty() || more) {
Column(Modifier.animateContentSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
for (field in shown) Fact(field)
if (more) {
Text("more…", Modifier.clickable { all = true }.padding(vertical = 2.dp),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
}
}
}
when {
details.description().isNotBlank() -> Description(details.description(), onLink)
details.pending() -> Text("Loading description…", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun Fact(field: Details.Field) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(field.label(), Modifier.width(96.dp), style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (field.groups().isEmpty()) {
Text(field.value(), style = MaterialTheme.typography.bodySmall)
} else {
// Each group with its icon, as TeamSpeak's info frame lists them.
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// A nameless group shows only its icon, and takes no comma.
val lastNamed = field.groups().indexOfLast { it.name.isNotBlank() }
field.groups().forEachIndexed { i, group ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
ServerIcon(group.iconId, size = 14.dp)
if (group.name.isNotBlank()) {
Text(group.name.trim() + if (i < lastNamed) "," else "", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
}
/** The description in a box that opens up, when there is more of it than fits folded. */
@Composable
private fun Description(bbcode: String, onLink: (String) -> Unit) {
val html = remember(bbcode) { BBCode.toHtml(bbcode, true) }
var expanded by rememberSaveable(bbcode) { mutableStateOf(false) }
var overflows by remember(bbcode) { mutableStateOf(false) }
val expandable = overflows || expanded
val background = MaterialTheme.colorScheme.surfaceContainerHigh
Surface(
Modifier.fillMaxWidth().then(if (expandable) Modifier.clickable { expanded = !expanded } else Modifier),
shape = RoundedCornerShape(12.dp),
color = background,
) {
Column(Modifier.animateContentSize().padding(12.dp)) {
Box {
Folded(FOLDED_HEIGHT, expanded, onOverflow = { overflows = it }) {
RichText(html, onLink, style = MaterialTheme.typography.bodyMedium)
}
if (overflows && !expanded) {
Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(24.dp)
.background(Brush.verticalGradient(listOf(Color.Transparent, background))))
}
}
if (expandable) {
Text(if (expanded) "Less" else "More", Modifier.padding(top = 4.dp),
style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
}
}
}
}
/** Its content cut to [height] unless [open], telling whether there is more of it than that. */
@Composable
private fun Folded(height: Dp, open: Boolean, onOverflow: (Boolean) -> Unit, content: @Composable () -> Unit) {
val report by rememberUpdatedState(onOverflow)
Layout(content, Modifier.clipToBounds()) { measurables, constraints ->
val placeable = measurables.first().measure(constraints.copy(maxHeight = Constraints.Infinity))
val limit = height.roundToPx()
report(placeable.height > limit)
val shown = if (open) placeable.height else minOf(placeable.height, limit)
layout(placeable.width, shown) { placeable.place(0, 0) }
}
}
private val FOLDED_HEIGHT = 128.dp

View File

@@ -0,0 +1,190 @@
package com.ts3client.android.ui
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.ts3client.gfx.IconKeys
import com.ts3client.net.ClientStatus
import com.ts3client.text.TextQuery
/** What the find bar holds: the text, how to match it, and whether it is [open]. */
@Stable
class FindState(text: String = "", flags: Int = 0) {
var open by mutableStateOf(false)
var input by mutableStateOf(TextFieldValue(text))
var matchCase by mutableStateOf(flags and 1 != 0)
var wholeWord by mutableStateOf(flags and 2 != 0)
var regex by mutableStateOf(flags and 4 != 0)
/** Counts the times it was asked to show, for the field to take the focus each time. */
var shown by mutableStateOf(0)
private set
/** Show only what matches. */
var filter by mutableStateOf(flags and 8 != 0)
/** In the tree, find channels rather than clients. */
var channels by mutableStateOf(flags and 16 != 0)
/** What is typed, as it is to be matched; empty while the bar is closed. */
val query: TextQuery
get() = if (open) TextQuery(input.text, matchCase, wholeWord, regex) else TextQuery.EMPTY
/** [query], but nothing when it is not a valid regular expression. */
val usable: TextQuery
get() = query.let { if (it.isInvalid) TextQuery.EMPTY else it }
/** Opens the bar with what it holds selected, to be typed over. */
fun show() {
input = input.copy(selection = TextRange(0, input.text.length))
open = true
shown++
}
companion object {
val Saver = listSaver<FindState, Any>(
save = {
val flags = (if (it.matchCase) 1 else 0) or (if (it.wholeWord) 2 else 0) or (if (it.regex) 4 else 0) or
(if (it.filter) 8 else 0) or (if (it.channels) 16 else 0)
listOf(it.input.text, flags, it.open)
},
restore = { FindState(it[0] as String, it[1] as Int).apply { open = it[2] as Boolean } },
)
}
}
/**
* The find bar, in the place of the top bar: the field, where the view is among the matches,
* previous/next, and the options. Enter goes to the next match, Shift+Enter to the previous.
*
* @param inTree whether it finds in the tree, which offers finding channels instead of clients
* @param position the 1-based match the view is on, or 0 for none
* @param more whether there are more matches than [total]
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FindBar(state: FindState, inTree: Boolean, position: Int, total: Int, more: Boolean, onStep: (Int) -> Unit) {
val focus = remember { FocusRequester() }
LaunchedEffect(state.shown) { focus.requestFocus() }
val query = state.query
TopAppBar(
expandedHeight = 48.dp,
navigationIcon = { IconButton(onClick = { state.open = false }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Close") } },
title = {
TextField(
state.input, { state.input = it },
Modifier.focusRequester(focus).onPreviewKeyEvent { e ->
when {
e.key == Key.Enter -> {
if (e.type == KeyEventType.KeyDown) onStep(if (e.isShiftPressed) -1 else 1)
true
}
e.key == Key.Escape && e.type == KeyEventType.KeyDown -> {
state.open = false
true
}
else -> false
}
},
placeholder = {
Text(if (!inTree) "Find" else if (state.channels) "Find channels" else "Find clients", maxLines = 1)
},
leadingIcon = if (inTree) ({
IconButton(onClick = { state.channels = !state.channels }) {
if (state.channels) PackIcon(IconKeys.channel(false, false, true).asList(), "Finding channels", size = 18.dp)
else PackIcon(IconKeys.client(ClientStatus.IDLE), "Finding clients", size = 18.dp)
}
}) else null,
trailingIcon = if (state.input.text.isNotEmpty()) ({
IconButton(onClick = { state.input = TextFieldValue() }) { Icon(Icons.Filled.Close, "Clear", Modifier.size(18.dp)) }
}) else null,
isError = query.isInvalid,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onStep(1) }),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
},
actions = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!query.isEmpty) {
Text(
if (query.isInvalid) "!" else "${if (position > 0) "$position/" else ""}$total${if (more) "+" else ""}",
Modifier.padding(horizontal = 4.dp),
color = if (query.isInvalid || total == 0) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
)
}
val navigable = total > 1 || (total == 1 && position == 0)
IconButton(onClick = { onStep(-1) }, enabled = navigable) { Icon(Icons.Filled.KeyboardArrowUp, "Previous") }
IconButton(onClick = { onStep(1) }, enabled = navigable) { Icon(Icons.Filled.KeyboardArrowDown, "Next") }
FindOptions(state)
}
},
)
}
@Composable
private fun FindOptions(state: FindState) {
var open by remember { mutableStateOf(false) }
IconButton(onClick = { open = true }) { Icon(Icons.Filled.MoreVert, "Options") }
DropdownMenu(open, onDismissRequest = { open = false }) {
@Composable
fun option(label: String, checked: Boolean, set: (Boolean) -> Unit) = DropdownMenuItem(
text = { Text(label) },
onClick = { set(!checked) },
leadingIcon = { Checkbox(checked, set) },
)
option("Only matches", state.filter) { state.filter = it }
option("Match case", state.matchCase) { state.matchCase = it }
option("Whole words", state.wholeWord) { state.wholeWord = it }
option("Regular expression", state.regex) { state.regex = it }
}
}

View File

@@ -0,0 +1,64 @@
package com.ts3client.android.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.Picture
import com.ts3client.android.WebPictures
import com.ts3client.net.HostButton
/**
* The server's host button in the top bar: its own picture fitted to the bar's icons, or
* the pack's host button icon while there is none (yet).
*/
@Composable
fun HostButtonAction(button: HostButton, onOpen: (String) -> Unit) {
val label = button.tooltip.ifEmpty { button.url }.ifEmpty { null }
val picture = hostPicture(button.gfxUrl)
Box(
Modifier
.fillMaxHeight()
.clickable(enabled = button.url.isNotEmpty()) { onOpen(button.url) }
.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center,
) {
if (picture == null) {
PackIcon("HOSTER_BUTTON", label)
} else {
Image(
rememberPicturePainter(picture),
label,
Modifier
.widthIn(max = 24.dp * MAX_ASPECT)
.height(24.dp)
.aspectRatio(picture.width.toFloat() / picture.height.coerceAtLeast(1)),
)
}
}
}
@Composable
private fun hostPicture(url: String): Picture? {
val pictures = LocalWebPictures.current
if (pictures == null || url.isEmpty()) return null
val version by pictures.version.collectAsStateWithLifecycle()
val state by produceState<WebPictures.State>(WebPictures.State.Loading, url, version) {
value = pictures.state(url)
}
return (state as? WebPictures.State.Shown)?.picture
}
/** Keeps a banner-shaped picture from crowding out the server's name. */
private const val MAX_ASPECT = 6

View File

@@ -0,0 +1,282 @@
package com.ts3client.android.ui
import android.content.ClipData
import android.content.ClipboardManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.IdentityInfo
import com.ts3client.android.IdentityManager
import kotlinx.coroutines.launch
/** Beyond this a search takes years; TeamSpeak's own dialog stops there too. */
private const val MAX_SECURITY_LEVEL = 40
/**
* The identities a server knows this client by, as TeamSpeak's Tools → Identities
* manages them, with each identity's own page.
*/
@Composable
fun IdentitiesScreen(manager: IdentityManager, onBack: () -> Unit) {
var editing by rememberSaveable { mutableStateOf<String?>(null) }
editing?.let { id -> return IdentityScreen(manager, id, onBack = { editing = null }) }
val identities by manager.all.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var menu by remember { mutableStateOf(false) }
var creating by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
fun work(failure: String, block: suspend () -> String?) {
busy = true
scope.launch {
message = try {
block()
} catch (e: Exception) {
"$failure: ${e.message ?: e}"
}
busy = false
}
}
val fileImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) work("That file is not a TeamSpeak identity") {
editing = manager.importFile(uri).id
null
}
}
val dbImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) work("That file is not a TeamSpeak 3 settings.db") {
val r = manager.importFromTeamSpeak(uri)
if (r.identities() == 0) "That file holds no identities."
else "Imported ${r.identitiesAdded()} of ${r.identities()} identities."
}
}
SubScreen("Identities", onBack, actions = {
IconButton(onClick = { creating = true }, enabled = !busy) { PackIcon("IDENTITY_ADD", "New identity") }
Box {
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "More") }
DropdownMenu(menu, { menu = false }) {
DropdownMenuItem({ Text("Import identity file") }, onClick = {
menu = false
fileImporter.launch(arrayOf("*/*"))
})
DropdownMenuItem({ Text("Import from TeamSpeak 3") }, onClick = {
menu = false
dbImporter.launch(arrayOf("*/*"))
})
}
}
}) { padding ->
LazyColumn(Modifier.padding(padding)) {
if (busy) item { LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) }
message?.let { item { Hint(it) } }
if (identities.isEmpty()) {
item {
Hint("No identities. One is created on the first connect.")
}
}
items(identities, key = { it.id }) { identity ->
ListItem(
headlineContent = { Text(identity.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = {
Text("Security level ${identity.securityLevel}", maxLines = 1)
},
trailingContent = if (identity.isDefault) ({ PackIcon("IDENTITY_DEFAULT", "Default") }) else null,
modifier = Modifier.clickable { editing = identity.id },
)
}
}
}
if (creating) {
NewIdentityDialog(onDismiss = { creating = false }) { name ->
creating = false
work("Could not create the identity") {
editing = manager.generate(name).id
null
}
}
}
}
/** One identity: its name, whether it is the default, its security level and unique ID. */
@Composable
private fun IdentityScreen(manager: IdentityManager, id: String, onBack: () -> Unit) {
val identities by manager.all.collectAsStateWithLifecycle()
val improving by manager.improving.collectAsStateWithLifecycle()
val identity = identities.firstOrNull { it.id == id }
if (identity == null) {
LaunchedEffect(Unit) { onBack() }
return
}
val scope = rememberCoroutineScope()
var name by rememberSaveable(id) { mutableStateOf(identity.name) }
var nickname by rememberSaveable(id) { mutableStateOf(identity.nickname) }
var phonetic by rememberSaveable(id) { mutableStateOf(identity.phoneticNickname) }
var removing by remember { mutableStateOf(false) }
var raising by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
val leave = {
val trimmed = name.trim()
if (trimmed.isNotEmpty() && trimmed != identity.name) manager.rename(id, trimmed)
if (nickname.trim() != identity.nickname || phonetic.trim() != identity.phoneticNickname) {
manager.setNicknames(id, nickname.trim(), phonetic.trim())
}
onBack()
}
val exporter = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
if (uri != null) scope.launch {
message = runCatching { manager.export(id, uri) }.fold(
{ "Exported." },
{ "Could not export: ${it.message ?: it}" },
)
}
}
val mine = improving?.takeIf { it.id == id }
SubScreen(identity.name, leave, actions = {
IconButton(onClick = { exporter.launch(fileName(name.ifBlank { identity.name })) }) {
PackIcon("IDENTITY_EXPORT", "Export")
}
IconButton(onClick = { removing = true }) { PackIcon("DELETE", "Remove") }
}) { padding ->
Column(Modifier.padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) {
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp),
label = { Text("Name") }, singleLine = true, isError = name.isBlank())
OutlinedTextField(nickname, { nickname = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp),
label = { Text("Nickname") }, placeholder = { Text("Global nickname") }, singleLine = true)
OutlinedTextField(phonetic, { phonetic = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
label = { Text("Phonetic nickname") }, singleLine = true)
message?.let { Hint(it) }
Section("Use")
if (identity.isDefault) {
Hint("Default identity")
} else {
LinkRow("Make default", null, "IDENTITY_DEFAULT") {
manager.setDefault(id)
}
}
Section("Security level")
if (mine != null) {
ListItem(
headlineContent = { Text("Improving to ${mine.target}: reached ${mine.reached}") },
supportingContent = { LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 8.dp)) },
trailingContent = { TextButton(onClick = manager::stopImproving) { Text("Stop") } },
)
} else {
LinkRow("Level ${identity.securityLevel}",
if (improving != null) "Another identity is being improved" else "Improve",
"SECURITY") { if (improving == null) raising = true }
}
Section("Details")
val context = LocalContext.current
LinkRow("Unique ID", identity.uniqueId) {
// The system confirms the copy itself.
context.getSystemService(ClipboardManager::class.java)
.setPrimaryClip(ClipData.newPlainText("Unique ID", identity.uniqueId))
}
}
}
if (raising) {
LevelDialog(identity, onDismiss = { raising = false }) { target ->
raising = false
manager.improve(id, target)
}
}
if (removing) {
AlertDialog(
onDismissRequest = { removing = false },
title = { Text("Remove ${identity.name}?") },
text = {
Text("Server groups and permissions tied to it are lost. Export it first to keep it.")
},
confirmButton = {
TextButton(onClick = {
removing = false
manager.remove(id)
}) { Text("Remove") }
},
dismissButton = { TextButton(onClick = { removing = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun NewIdentityDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New identity") },
text = {
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true)
},
confirmButton = { TextButton(onClick = { onConfirm(name.trim()) }, enabled = name.isNotBlank()) { Text("Create") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun LevelDialog(identity: IdentityInfo, onDismiss: () -> Unit, onConfirm: (Int) -> Unit) {
var text by remember { mutableStateOf((identity.securityLevel + 1).toString()) }
val level = text.trim().toIntOrNull()?.takeIf { it in identity.securityLevel + 1..MAX_SECURITY_LEVEL }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Improve security level") },
text = {
Column {
Text("Current level: ${identity.securityLevel}", style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(text, { text = it }, Modifier.fillMaxWidth().padding(top = 8.dp),
label = { Text("Target level") }, singleLine = true, isError = level == null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
}
},
confirmButton = { TextButton(onClick = { level?.let(onConfirm) }, enabled = level != null) { Text("Start") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
private fun fileName(name: String) = name.replace(Regex("[^\\w.-]+"), "_") + ".ini"

View File

@@ -0,0 +1,225 @@
package com.ts3client.android.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.ts3client.android.ClientItem
import com.ts3client.android.ServerController
import com.ts3client.net.GroupChoice
import com.ts3client.net.TeamspeakConnection
import com.ts3client.net.ban.BanRule
import com.ts3client.net.ban.BanUnit
/** Asks to confirm a kick, with an optional reason. */
@Composable
fun KickPrompt(title: String, onDismiss: () -> Unit, onKick: (String) -> Unit) {
var reason by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { LimitedField(reason, { reason = it }, "Reason", TeamspeakConnection.KICK_REASON_LIMIT) },
confirmButton = { TextButton(onClick = { onKick(reason.trim()) }) { Text("Kick") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
/** Asks for a ban's reason and length. */
@Composable
fun BanPrompt(name: String, maxSeconds: Long, onDismiss: () -> Unit, onBan: (seconds: Long, reason: String) -> Unit) {
var reason by remember { mutableStateOf("") }
val duration = remember { BanDurationState(DEFAULT_BAN_SECONDS) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Ban $name") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
LimitedField(reason, { reason = it }, "Reason", BanRule.REASON_LIMIT)
BanDurationField(duration, maxSeconds)
}
},
confirmButton = {
TextButton(onClick = { onBan(duration.seconds, reason.trim()) }, enabled = duration.valid) { Text("Ban") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
/** Thirty minutes, as the official client starts its ban dialog. */
internal const val DEFAULT_BAN_SECONDS = 30 * 60L
/** A one-line field that takes no more than [limit] characters, counting them as they come. */
@Composable
internal fun LimitedField(value: String, onChange: (String) -> Unit, label: String, limit: Int, modifier: Modifier = Modifier) {
OutlinedTextField(
value, { onChange(it.take(limit)) },
modifier.fillMaxWidth(),
label = { Text(label) },
singleLine = true,
supportingText = { Text("${value.length}/$limit", Modifier.fillMaxWidth(), textAlign = TextAlign.End) },
)
}
/** A ban's length as typed: an amount of a [BanUnit]. */
internal class BanDurationState(seconds: Long) {
var unit by mutableStateOf(BanUnit.largestFor(seconds))
var amount by mutableStateOf(BanUnit.largestFor(seconds).amountOf(seconds).takeIf { it > 0 }?.toString() ?: "30")
val valid: Boolean get() = unit == BanUnit.PERMANENT || (amount.toLongOrNull() ?: 0) > 0
/** The length in seconds; 0 for permanent. */
val seconds: Long get() = unit.toSeconds(amount.toLongOrNull() ?: 0)
fun set(seconds: Long) {
unit = BanUnit.largestFor(seconds)
if (unit != BanUnit.PERMANENT) amount = unit.amountOf(seconds).toString()
}
}
/** The amount, its unit, and a jump to the longest ban the server lets us hand out. */
@Composable
internal fun BanDurationField(state: BanDurationState, maxSeconds: Long, modifier: Modifier = Modifier) {
var units by remember { mutableStateOf(false) }
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
if (state.unit == BanUnit.PERMANENT) "" else state.amount,
{ text -> state.amount = text.filter(Char::isDigit).take(9) },
Modifier.width(120.dp),
enabled = state.unit != BanUnit.PERMANENT,
label = { Text("Duration") },
singleLine = true,
isError = !state.valid,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
Box(Modifier.weight(1f)) {
OutlinedButton(onClick = { units = true }, Modifier.fillMaxWidth()) { Text(state.unit.label()) }
DropdownMenu(units, { units = false }) {
for (unit in BanUnit.entries) {
DropdownMenuItem({ Text(unit.label()) }, onClick = { units = false; state.unit = unit })
}
}
}
if (maxSeconds != 0L) TextButton(onClick = { state.set(maxSeconds) }) { Text("Max") }
}
}
/**
* A client's groups to tick: several server groups, or with [single] the one channel group.
* Groups we have no power over show greyed out; a change shows once the server made it.
*/
@Composable
fun GroupsDialog(
title: String,
choices: List<GroupChoice>,
single: Boolean,
onDismiss: () -> Unit,
onChange: (groupId: Int, assign: Boolean) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
LazyColumn {
items(choices, key = { it.group().id }) { choice ->
val group = choice.group()
// A client always has one channel group: picking another is the only change.
val changeable = choice.allowed() && !(single && choice.assigned())
val toggle = { if (changeable) onChange(group.id, !choice.assigned()) }
Row(
Modifier.fillMaxWidth()
.clickable(enabled = changeable, onClick = toggle)
.alpha(if (choice.allowed()) 1f else 0.38f)
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (single) {
RadioButton(choice.assigned(), onClick = toggle, enabled = choice.allowed())
} else {
Checkbox(choice.assigned(), onCheckedChange = { toggle() }, enabled = choice.allowed())
}
Box(Modifier.width(24.dp), contentAlignment = Alignment.Center) {
if (group.iconId != 0L) ServerIcon(group.iconId)
}
Text(group.name, Modifier.padding(start = 8.dp))
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
}
/** The kicks, the ban and the groups of [client], as far as we may use them; [onDone] once one went out. */
@Composable
fun ClientModeration(server: ServerController, client: ClientItem, onDone: () -> Unit) {
var kick by remember { mutableStateOf<Boolean?>(null) }
var banning by remember { mutableStateOf(false) }
var groups by remember { mutableStateOf<Boolean?>(null) }
val serverGroups = remember(client) { server.serverGroupChoices(client.id) }
val channelGroups = remember(client) { server.channelGroupChoices(client.id) }
if (!client.self) {
if (server.canKickFromChannel) Action("KICK_FROM_CHANNEL", "Kick from channel") { kick = false }
if (server.canKickFromServer) Action("KICK_FROM_SERVER", "Kick from server") { kick = true }
if (server.canBanClients) Action("BAN_CLIENT", "Ban") { banning = true }
}
if (serverGroups.any { it.allowed() }) Action("PERMISSIONS_SERVER_GROUPS", "Server groups") { groups = true }
if (channelGroups.any { it.allowed() && !it.assigned() }) {
Action("PERMISSIONS_CHANNEL_GROUPS", "Channel group") { groups = false }
}
kick?.let { fromServer ->
KickPrompt(
if (fromServer) "Kick ${client.name} from the server?" else "Kick ${client.name} from the channel?",
onDismiss = { kick = null },
) { reason ->
kick = null
if (fromServer) server.kickFromServer(client.id, reason) else server.kickFromChannel(client.id, reason)
onDone()
}
}
if (banning) {
BanPrompt(client.name, server.maxBanSeconds, onDismiss = { banning = false }) { seconds, reason ->
banning = false
server.ban(client.id, seconds, reason)
onDone()
}
}
groups?.let { ofServer ->
GroupsDialog(
if (ofServer) "Server groups" else "Channel group",
if (ofServer) serverGroups else channelGroups,
single = !ofServer,
onDismiss = { groups = null },
) { groupId, assign ->
if (ofServer) server.setServerGroup(client.id, groupId, assign) else server.setChannelGroup(client.id, groupId)
}
}
}

View File

@@ -0,0 +1,219 @@
package com.ts3client.android.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ts3client.android.SessionController
import com.ts3client.myts.MyTeamSpeak
import com.ts3client.myts.MyTsException
import com.ts3client.teamspeak.SyncItem
import kotlinx.coroutines.launch
import java.io.IOException
private data class ItemRow(val item: SyncItem, val imported: Boolean, val selected: Boolean)
/**
* The myTeamSpeak account: sign in and out, and pick what the account synchronises to
* import. Signing in lasts until signing out; the password itself is not kept.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyTeamSpeakScreen(vm: SessionController, onBack: () -> Unit) {
val login = vm.myTeamSpeak
val scope = rememberCoroutineScope()
var signedIn by remember { mutableStateOf(login.isSignedIn) }
var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf(false) }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var all by remember { mutableStateOf(emptyList<SyncItem>()) }
var rows by remember { mutableStateOf(emptyList<ItemRow>()) }
BackHandler(onBack = onBack)
fun show(account: MyTeamSpeak.Account) {
all = account.items()
rows = all.filter { it !is SyncItem.Folder }.map {
val imported = vm.isImported(it)
ItemRow(it, imported, !imported)
}
message = if (rows.isEmpty()) "The account holds no bookmarks or identities." else null
error = false
}
fun work(block: suspend () -> Unit) {
busy = true
message = null
scope.launch {
try {
block()
} catch (e: MyTsException) {
message = e.message
error = true
} catch (e: IOException) {
message = "Could not reach myTeamSpeak: ${e.message}"
error = true
}
// A sign-in the server no longer takes has signed out.
signedIn = login.isSignedIn
busy = false
}
}
LaunchedEffect(Unit) {
if (signedIn) work { show(vm.fetchMyTeamSpeak()) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("myTeamSpeak") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
actions = {
if (signedIn) {
IconButton(onClick = { work { show(vm.fetchMyTeamSpeak()) } }, enabled = !busy) {
PackIcon("REFRESH", "Refresh")
}
}
},
)
},
) { padding ->
LazyColumn(
Modifier.padding(padding).imePadding(),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (busy) item { LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) }
if (!signedIn) {
item {
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Sign in to bring over the bookmarks and identities your account synchronises. " +
"The app stays signed in; your password itself is not kept.")
OutlinedTextField(email, { email = it }, Modifier.fillMaxWidth(), label = { Text("Email") },
singleLine = true, enabled = !busy,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email))
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Password") },
singleLine = true, enabled = !busy, visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
Button(
onClick = {
work {
show(vm.signInMyTeamSpeak(email.trim(), password))
password = ""
}
},
enabled = !busy && email.isNotBlank() && password.isNotEmpty(),
) { Text("Sign in") }
}
}
} else {
item {
Row(Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Signed in as ${login.username()}", style = MaterialTheme.typography.titleMedium)
Text(login.email(), style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
OutlinedButton(onClick = {
login.signOut()
signedIn = false
rows = emptyList()
all = emptyList()
message = null
}) { Text("Sign out") }
}
}
if (rows.isNotEmpty()) {
item {
Text("Synchronised by the account", Modifier.padding(start = 16.dp, top = 16.dp),
style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
}
}
items(rows, key = { it.item.uuid() }) { row ->
val toggle = { rows = rows.map { if (it === row) it.copy(selected = !it.selected) else it } }
ListItem(
headlineContent = { Text(name(row.item), maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = { Text(details(row.item), maxLines = 1, overflow = TextOverflow.Ellipsis) },
leadingContent = { Checkbox(row.selected, { toggle() }) },
trailingContent = if (row.imported) ({ Text("Imported", style = MaterialTheme.typography.labelMedium) }) else null,
modifier = Modifier.clickable(onClick = toggle),
)
}
if (rows.isNotEmpty()) {
item {
Button(
onClick = {
work {
val r = vm.importMyTeamSpeak(all, rows.filter { it.selected }.map { it.item })
rows = rows.map { it.copy(imported = vm.isImported(it.item), selected = false) }
message = "Imported ${count(r.identitiesAdded(), "identity", "identities")} and " +
"${count(r.bookmarksAdded(), "bookmark", "bookmarks")}."
}
},
enabled = !busy && rows.any { it.selected },
modifier = Modifier.padding(horizontal = 16.dp),
) { Text("Import selected") }
}
}
}
message?.let { text ->
item {
Text(text, Modifier.padding(horizontal = 16.dp),
color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
}
}
}
}
}
private fun name(item: SyncItem) = when (item) {
is SyncItem.Bookmark -> item.name().ifBlank { item.address() }
is SyncItem.Identity -> "Identity: ${item.name()}"
else -> ""
}
private fun details(item: SyncItem) = when (item) {
is SyncItem.Bookmark -> "${item.address()}:${item.port()}"
is SyncItem.Identity -> item.nickname()
else -> ""
}
private fun count(n: Int, one: String, many: String) = "$n ${if (n == 1) one else many}"

View File

@@ -0,0 +1,42 @@
package com.ts3client.android.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.IconTheme
val LocalIconTheme = staticCompositionLocalOf<IconTheme> { error("No icon theme provided") }
/** An icon of the active pack, named by its TeamSpeak key; art in the text colour takes [LocalContentColor]. */
@Composable
fun PackIcon(key: String, contentDescription: String?, modifier: Modifier = Modifier, size: Dp = 24.dp) =
PackIcon(listOf(key), contentDescription, modifier, size)
/** The first of [keys] the pack has, for icons packs need not ship in every variant. */
@Composable
fun PackIcon(keys: List<String>, contentDescription: String?, modifier: Modifier = Modifier, size: Dp = 24.dp) {
val theme = LocalIconTheme.current
val active by theme.active.collectAsStateWithLifecycle()
val pixels = with(LocalDensity.current) { size.roundToPx() }
val color = LocalContentColor.current
val rgb = color.copy(alpha = 1f).toArgb()
val bitmap = remember(keys, pixels, rgb, active) { theme.render(keys, pixels, rgb)?.asImageBitmap() }
if (bitmap == null) {
Spacer(modifier.size(size))
} else {
// Disabled controls fade their content colour; the pack's own colours fade with it.
Image(bitmap, contentDescription, modifier.size(size), alpha = color.alpha)
}
}

View File

@@ -0,0 +1,220 @@
package com.ts3client.android.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.ts3client.android.Picture
import com.ts3client.android.SavedPictures
import com.ts3client.android.ServerController
import com.ts3client.android.decodePicture
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** A picture to open full size. */
class ViewedPicture(
/** What to call it when saved, without an extension. */
val name: String,
/** Where it is on the web; null for an avatar. */
val url: String? = null,
/** Follows the link it sits in; null when it sits in none. */
val openLink: (() -> Unit)? = null,
/** Our own avatar, which there is no taking. */
val ownAvatar: Boolean = false,
/** Its file; null when it is not there. */
val read: suspend () -> ByteArray?,
)
/** Opens a picture full size; null where nothing can. */
val LocalPictureViewer = staticCompositionLocalOf<((ViewedPicture) -> Unit)?> { null }
/** Lets [content] open pictures full size, over everything, with [server] to take one as our avatar on. */
@Composable
fun PictureViewerHost(server: ServerController, content: @Composable () -> Unit) {
var viewed by remember { mutableStateOf<ViewedPicture?>(null) }
CompositionLocalProvider(LocalPictureViewer provides { viewed = it }, content = content)
val context = LocalContext.current
viewed?.let { picture ->
PictureViewer(
picture,
onSteal = if (picture.ownAvatar) null else { data ->
val error = server.setAvatar(data)
Toast.makeText(context, error ?: "Avatar set.", Toast.LENGTH_SHORT).show()
},
onDismiss = { viewed = null },
)
}
}
/** A name to save the picture at [url] under: its file's, or "picture" when it has none. */
fun pictureName(url: String): String =
url.substringBefore('?').substringBefore('#').trimEnd('/').substringAfterLast('/')
.substringBeforeLast('.').ifBlank { "picture" }
/** The picture on black, filling the screen; pinch or double-tap to zoom, tap to hide the bar. */
@Composable
private fun PictureViewer(
picture: ViewedPicture,
/** Takes the picture as our avatar; null when it cannot be. */
onSteal: (suspend (ByteArray) -> Unit)?,
onDismiss: () -> Unit,
) {
val state by produceState<Shown?>(null, picture) {
val data = picture.read()
value = Shown(data, data?.let { withContext(Dispatchers.Default) { decodePicture(it, MAX_PX) } })
}
val data = state?.data
var bar by remember { mutableStateOf(true) }
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var size by remember { mutableStateOf(IntSize.Zero) }
fun panTo(target: Offset) {
val maxX = size.width * (scale - 1) / 2
val maxY = size.height * (scale - 1) / 2
offset = Offset(target.x.coerceIn(-maxX, maxX), target.y.coerceIn(-maxY, maxY))
}
Dialog(onDismiss, DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
Box(
Modifier.fillMaxSize().background(Color.Black).onSizeChanged { size = it }
.pointerInput(Unit) {
detectTapGestures(
onTap = { bar = !bar },
onDoubleTap = { scale = if (scale > 1f) 1f else DOUBLE_TAP_SCALE; panTo(Offset.Zero) },
)
}
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, MAX_SCALE)
panTo(offset + pan)
}
},
contentAlignment = Alignment.Center,
) {
val shown = state?.picture
when {
state == null -> PackIcon("LOADING_IMAGE", "Loading picture")
shown == null -> PackIcon("BROKEN_IMAGE", "Broken picture")
else -> Image(
rememberPicturePainter(shown), null,
Modifier.fillMaxSize().graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
contentScale = ContentScale.Fit,
)
}
if (bar) {
CompositionLocalProvider(LocalContentColor provides Color.White) {
Bar(picture, data, onSteal, onDismiss, Modifier.align(Alignment.TopCenter))
}
}
}
}
}
@Composable
private fun Bar(
picture: ViewedPicture,
data: ByteArray?,
onSteal: (suspend (ByteArray) -> Unit)?,
onDismiss: () -> Unit,
modifier: Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var menu by remember { mutableStateOf(false) }
Row(
modifier.fillMaxWidth()
.background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.6f), Color.Transparent)))
.statusBarsPadding(),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onDismiss) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
Box(Modifier.weight(1f))
Box {
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "Picture options") }
DropdownMenu(menu, onDismissRequest = { menu = false }) {
DropdownMenuItem({ Text("Save") }, enabled = data != null, onClick = {
menu = false
val bytes = data ?: return@DropdownMenuItem
scope.launch {
val saved = SavedPictures.save(context, picture.name, bytes)
Toast.makeText(context, if (saved) "Saved." else "Could not save.", Toast.LENGTH_SHORT).show()
}
})
if (onSteal != null) {
DropdownMenuItem({ Text("Steal for avatar") }, enabled = data != null, onClick = {
menu = false
val bytes = data ?: return@DropdownMenuItem
scope.launch { onSteal(bytes) }
})
}
picture.url?.let { url ->
DropdownMenuItem({ Text("Copy URL") }, onClick = {
menu = false
context.getSystemService(ClipboardManager::class.java)
.setPrimaryClip(ClipData.newPlainText("URL", url))
})
}
picture.openLink?.let { open ->
DropdownMenuItem({ Text("Open link") }, onClick = {
menu = false
onDismiss()
open()
})
}
}
}
}
}
/** The picture's file, and the picture when the file is one; both null when it is not there. */
private class Shown(val data: ByteArray?, val picture: Picture?)
private const val MAX_PX = 4096
private const val MAX_SCALE = 8f
private const val DOUBLE_TAP_SCALE = 2.5f

View File

@@ -0,0 +1,140 @@
package com.ts3client.android.ui
import android.graphics.BitmapFactory
import android.graphics.drawable.AnimatedImageDrawable
import android.graphics.drawable.Drawable
import android.os.Handler
import android.os.Looper
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.Picture
import com.ts3client.android.ServerImages
import com.ts3client.gfx.CountryFlags
import com.ts3client.net.IconRepository
import java.util.Optional
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.roundToInt
/** The images of the server on screen. */
val LocalServerImages = staticCompositionLocalOf<ServerImages?> { null }
/**
* A group, channel or server icon; nothing while it has none or it is still downloading.
* TeamSpeak's default group icons are the pack's, drawn in its style, as on the desktop.
*/
@Composable
fun ServerIcon(id: Long, modifier: Modifier = Modifier, size: Dp = 16.dp) {
if (id == 0L) return
if (id in 1..IconRepository.MAX_BUNDLED_ID) {
val theme = LocalIconTheme.current
val active by theme.active.collectAsStateWithLifecycle()
val pixels = with(LocalDensity.current) { size.roundToPx() }
val rgb = LocalContentColor.current.copy(alpha = 1f).toArgb()
val themed = remember(id, pixels, rgb, active) { theme.render(listOf("GROUP_$id"), pixels, rgb)?.asImageBitmap() }
if (themed != null) {
Image(themed, null, modifier.size(size))
return
}
}
val images = LocalServerImages.current ?: return
val version by images.version.collectAsStateWithLifecycle()
val bitmap = remember(id, version) { images.icon(id) } ?: return
Image(bitmap, null, modifier.size(size))
}
/** A client's avatar, moving if it is an animated GIF; nothing while they have none or it is still downloading. */
@Composable
fun Avatar(uniqueId: String, flag: String, modifier: Modifier = Modifier, size: Dp = 64.dp) {
val images = LocalServerImages.current ?: return
val version by images.version.collectAsStateWithLifecycle()
val picture = remember(flag, version) { images.avatar(uniqueId, flag) } ?: return
Image(rememberPicturePainter(picture), "Avatar", modifier.size(size).clip(RoundedCornerShape(8.dp)))
}
/** Draws [picture], playing it while on screen if it moves. */
@Composable
fun rememberPicturePainter(picture: Picture): Painter = when (picture) {
is Picture.Still -> remember(picture) { BitmapPainter(picture.bitmap) }
is Picture.Moving -> rememberMovingPainter(picture.drawable)
}
@Composable
private fun rememberMovingPainter(drawable: AnimatedImageDrawable): Painter {
val painter = remember(drawable) { MovingPainter(drawable) }
DisposableEffect(drawable) {
drawable.callback = painter
drawable.start()
onDispose {
drawable.stop()
if (drawable.callback === painter) drawable.callback = null
}
}
return painter
}
private class MovingPainter(private val drawable: AnimatedImageDrawable) : Painter(), Drawable.Callback {
/** Read while drawing, so that each new frame draws again. */
private var frame by mutableIntStateOf(0)
private val main = Handler(Looper.getMainLooper())
override val intrinsicSize = Size(drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
override fun DrawScope.onDraw() {
frame
drawIntoCanvas { canvas ->
drawable.setBounds(0, 0, size.width.roundToInt(), size.height.roundToInt())
drawable.draw(canvas.nativeCanvas)
}
}
override fun invalidateDrawable(who: Drawable) {
frame++
}
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
main.postAtTime(what, `when`)
}
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
main.removeCallbacks(what)
}
}
private val flags = ConcurrentHashMap<String, Optional<ImageBitmap>>()
/** The flag of a country, at TeamSpeak's 16×11; nothing for no country or one without a flag. */
@Composable
fun CountryFlag(code: String, modifier: Modifier = Modifier) {
if (code.isEmpty()) return
val bitmap = remember(code) {
flags.getOrPut(code) {
Optional.ofNullable(CountryFlags.png(code)?.let { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() })
}.orElse(null)
} ?: return
Image(bitmap, CountryFlags.name(code), modifier.size(16.dp, 11.dp))
}

View File

@@ -0,0 +1,584 @@
package com.ts3client.android.ui
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.ChatKey
import com.ts3client.android.ChatMatch
import com.ts3client.android.ServerController
import com.ts3client.android.ServerTree
import com.ts3client.android.SessionController
import com.ts3client.android.SessionState
import com.ts3client.android.TreeFind
import com.ts3client.android.findIn
import com.ts3client.config.Settings
import com.ts3client.net.ClientStatus
import com.ts3client.text.TsLink
import kotlinx.coroutines.flow.MutableStateFlow
private val ChatKeySaver = Saver<ChatKey, String>(
save = { key ->
when (key) {
ChatKey.Server -> "server"
ChatKey.Channel -> "channel"
is ChatKey.Private -> "private:${key.clientId}"
}
},
restore = { saved ->
when {
saved == "server" -> ChatKey.Server
saved.startsWith("private:") -> ChatKey.Private(saved.substringAfter(':').toInt())
else -> ChatKey.Channel
}
},
)
/** A connection: the channel tree and the chat, with the voice controls underneath. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServerScreen(
vm: SessionController,
server: ServerController,
snackbar: SnackbarHostState,
onSettings: () -> Unit,
/** Opens the given client's contact page. */
onEditContact: (String) -> Unit,
onEnableMicrophone: () -> Unit,
onConnectOther: () -> Unit,
) {
val state by server.state.collectAsStateWithLifecycle()
val tree by server.tree.collectAsStateWithLifecycle()
val chats by server.chats.collectAsStateWithLifecycle()
val collapsed by server.collapsed.collectAsStateWithLifecycle()
var showChat by rememberSaveable { mutableStateOf(false) }
var chatKey by rememberSaveable(stateSaver = ChatKeySaver) { mutableStateOf<ChatKey>(ChatKey.Channel) }
val conversation = chats[chatKey] ?: chats.channel
var clientSheet by remember { mutableStateOf<Int?>(null) }
var clientSheetFromTree by remember { mutableStateOf(false) }
/** A row to show in the tree. */
var reveal by remember { mutableStateOf<TreeRef?>(null) }
var channelSheet by remember { mutableStateOf<Int?>(null) }
var serverSheet by remember { mutableStateOf(false) }
var banList by rememberSaveable { mutableStateOf(false) }
/** The client to move, while a channel for them is being picked. */
var moving by remember { mutableStateOf<Int?>(null) }
val infoUpdates by server.infoUpdates.collectAsStateWithLifecycle()
val chatRequest by server.chatRequest.collectAsStateWithLifecycle()
LaunchedEffect(chatRequest) {
val key = chatRequest ?: return@LaunchedEffect
chatKey = if (chats[key] != null) key else server.openPrivateChat(key.clientId)
showChat = true
server.chatRequestHandled()
}
val viewing = if (showChat) conversation.key else null
LifecycleResumeEffect(viewing) {
server.viewing = viewing
onPauseOrDispose { server.viewing = null }
}
val uriHandler = LocalUriHandler.current
val onLink: (String) -> Unit = { href ->
val ref = TsLink.parse(href)
when {
ref == null -> runCatching { uriHandler.openUri(href) }
ref.client -> { clientSheet = ref.id; clientSheetFromTree = false }
else -> channelSheet = ref.id
}
}
// Find: in the tree, clients or channels; in the chat, the conversation on screen.
val find = rememberSaveable(saver = FindState.Saver) { FindState() }
val treeFind = TreeFind(find.usable, find.channels)
val treeHits = remember(tree, treeFind) { tree.hits(treeFind) }
val treeFiltering = find.filter && !treeFind.query.isEmpty
val shownTree = remember(tree, treeFind, treeFiltering) { if (treeFiltering) tree.only(treeFind) else tree }
// While the tree is filtered every channel opens, so every match shows; folding one then lasts as long as the filter.
var filterCollapsed by remember(treeFiltering, treeFind) { mutableStateOf(emptySet<Int>()) }
/** The match the tree is on. */
var treeAt by remember { mutableStateOf<Int?>(null) }
// Lambdas, not local functions: Compose remembers a reference to a local function with what it first captured.
val showTreeHit = { id: Int? ->
if (id != null) {
treeAt = id
val path = (if (treeFind.channels) tree.parentsOf(id) else tree.pathTo(id)).orEmpty()
if (treeFiltering) filterCollapsed = filterCollapsed - path.toSet() else server.expand(path)
reveal = TreeRef(treeFind.channels, id)
}
}
LaunchedEffect(treeFind, showChat) {
treeAt = null
if (!showChat) showTreeHit(treeHits.firstOrNull())
}
val chatQuery = find.usable
val chatLines = remember(conversation.lines, chatQuery, find.filter) {
if (find.filter && !chatQuery.isEmpty) conversation.lines.filter { chatQuery.matches(it.plain) } else conversation.lines
}
val chatHits = remember(chatLines, chatQuery) { findIn(chatLines, chatQuery, MAX_CHAT_MATCHES) }
/** The match the chat is on; a new search starts from the newest. */
var chatAt by remember { mutableStateOf<ChatMatch?>(null) }
var chatReveal by remember { mutableStateOf<Long?>(null) }
LaunchedEffect(chatQuery, conversation.key, find.filter, showChat) {
chatAt = if (showChat) chatHits.lastOrNull() else null
chatReveal = chatAt?.line
}
val step = { delta: Int ->
if (showChat && chatHits.isNotEmpty()) {
chatAt = chatHits[next(chatHits.indexOf(chatAt), delta, chatHits.size)]
chatReveal = chatAt?.line
} else if (!showChat && treeHits.isNotEmpty()) {
showTreeHit(treeHits[next(treeHits.indexOf(treeAt), delta, treeHits.size)])
}
}
if (banList) {
BanListScreen(server, vm.banReasons, onBack = { banList = false })
return
}
BackHandler(showChat) { showChat = false }
BackHandler(moving != null && !showChat) { moving = null }
BackHandler(find.open) { find.open = false }
Scaffold(
// A hardware keyboard's Ctrl+F, as on the desktop.
modifier = Modifier.onPreviewKeyEvent { e ->
val ctrlF = e.isCtrlPressed && e.key == Key.F
if (ctrlF && e.type == KeyEventType.KeyDown) find.show()
ctrlF
},
topBar = {
if (find.open) {
if (showChat) {
FindBar(find, inTree = false, chatHits.indexOf(chatAt) + 1, chatHits.size,
chatHits.size == MAX_CHAT_MATCHES, step)
} else {
FindBar(find, inTree = true, treeHits.indexOf(treeAt) + 1, treeHits.size, false, step)
}
} else {
TopAppBar(
title = { ServerSwitcher(vm, server, state, onConnectOther) },
expandedHeight = 48.dp,
actions = {
state.hostButton?.let { HostButtonAction(it) { url -> runCatching { uriHandler.openUri(url) } } }
IconButton(onClick = find::show) { PackIcon("FIND", "Find") }
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
},
)
}
},
bottomBar = {
// Push-to-talk talks on whichever server holds the microphone, which need not be this one.
val owner by vm.micOwner.collectAsStateWithLifecycle()
val ownerState by remember(owner) { owner?.state ?: MutableStateFlow(SessionState()) }
.collectAsStateWithLifecycle()
val ownerTree by remember(owner) { owner?.tree ?: MutableStateFlow(ServerTree()) }
.collectAsStateWithLifecycle()
val self = ownerTree.client(ownerState.selfClientId)
val talking = self?.status == ClientStatus.TALKING || self?.status == ClientStatus.COMMANDER_TALKING
VoiceBar(state, owner != null, talking, vm, server, onEnableMicrophone, showChat, chats.anyUnread) {
showChat = !showChat
}
},
snackbarHost = { SnackbarHost(snackbar) },
) { padding ->
Column(Modifier.padding(padding).consumeWindowInsets(padding).imePadding()) {
if (!showChat) {
moving?.let { id ->
val client = tree.client(id)
if (client == null) {
LaunchedEffect(id) { moving = null }
} else {
MovePrompt(client.name) { moving = null }
}
}
ChannelTree(
serverName = state.title,
tree = shownTree,
collapsed = if (treeFiltering) filterCollapsed else collapsed,
currentChannel = state.channelId,
onToggle = {
if (!treeFiltering) server.toggleCollapsed(it.id)
else if (it.id in filterCollapsed) filterCollapsed -= it.id
else filterCollapsed += it.id
},
onChannel = { channel ->
val client = moving
when {
channel.spacer != null -> {}
client != null -> { server.moveClient(client, channel.id); moving = null }
else -> channelSheet = channel.id
}
},
onClient = { clientSheet = it.id; clientSheetFromTree = true },
onServer = { serverSheet = true },
modifier = Modifier.weight(1f),
reveal = reveal,
onRevealed = { reveal = null },
found = remember(treeHits) { treeHits.mapTo(HashSet()) { TreeRef(treeFind.channels, it) } },
foundCurrent = treeAt?.takeIf { it in treeHits }?.let { TreeRef(treeFind.channels, it) },
)
} else {
ChatPane(
chats = chats,
selected = conversation,
canSend = state.connected,
onSelect = { chatKey = it },
onClose = { key ->
if (chatKey == key) chatKey = ChatKey.Channel
server.closePrivateChat(key)
},
onSend = { server.send(conversation.key, it) },
onLink = onLink,
modifier = Modifier.weight(1f),
lines = chatLines,
find = chatQuery,
current = chatAt,
reveal = chatReveal,
onRevealed = { chatReveal = null },
)
}
}
}
// A sheet goes with its client or channel when that leaves the tree.
clientSheet?.let { id ->
LaunchedEffect(id) { server.requestClientDetails(id) }
tree.client(id)?.let { client ->
ClientSheet(
client = client,
details = remember(id, tree, infoUpdates) { server.clientDetails(id) },
onLink = onLink,
away = state.away,
initialVolume = remember(id) { server.clientVolume(id) },
onDismiss = { clientSheet = null },
onMessage = {
clientSheet = null
chatKey = server.openPrivateChat(id)
showChat = true
},
onPoke = { server.poke(id, it); clientSheet = null },
onVolume = { server.setClientVolume(id, it) },
onAway = { server.setAway(it); clientSheet = null },
onAddContact = { server.addContact(id, it) },
onEditContact = { clientSheet = null; onEditContact(client.uniqueId) },
onMove = if (server.canMoveClients) ({ clientSheet = null; moving = id }) else null,
onMoveToOwnChannel = if (server.canMoveClients && tree.channel(state.channelId)?.clients?.none { it.id == id } == true) {
{ server.moveClient(id, state.channelId); clientSheet = null }
} else null,
onFindInTree = if (clientSheetFromTree) null else ({
clientSheet = null
tree.pathTo(id)?.let { server.expand(it) }
showChat = false
reveal = TreeRef(false, id)
}),
moderation = { ClientModeration(server, client) { clientSheet = null } },
)
}
}
channelSheet?.let { id ->
LaunchedEffect(id) { server.requestChannelDetails(id) }
tree.channel(id)?.let { channel ->
ChannelSheet(
channel = channel,
details = remember(id, tree, infoUpdates) { server.channelDetails(id) },
onLink = onLink,
current = id == state.channelId,
onDismiss = { channelSheet = null },
onJoin = { server.joinChannel(id, it); channelSheet = null },
onSubscribe = { server.setSubscribed(id, it); channelSheet = null },
)
}
}
if (serverSheet) {
remember(tree, infoUpdates) { server.serverDetails() }?.let {
ServerSheet(it, onLink, onDismiss = { serverSheet = false },
onBanList = if (server.canViewBanList) ({ serverSheet = false; banList = true }) else null)
}
}
}
/** More matches than this nobody steps through one by one. */
private const val MAX_CHAT_MATCHES = 5000
/** The index [delta] on from [at] among [size], wrapping around; from none, the first or the last. */
private fun next(at: Int, delta: Int, size: Int): Int =
if (at < 0) (if (delta > 0) 0 else size - 1) else Math.floorMod(at + delta, size)
/** Says a channel is being picked for [name] to move to, until it is or [onCancel]. */
@Composable
private fun MovePrompt(name: String, onCancel: () -> Unit) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically) {
PackIcon("CHANNEL_SWITCH", null)
Text("Move $name to…", Modifier.weight(1f).padding(horizontal = 12.dp), maxLines = 1,
overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
TextButton(onClick = onCancel) { Text("Cancel") }
}
}
}
/**
* Mute, deafen, away and the chat, around the push-to-talk button when that is how the
* microphone is used. Holding the mute button switches between push to talk and voice activation.
*/
@Composable
private fun VoiceBar(
state: SessionState,
/** Whether some server holds the microphone, for push-to-talk to talk on. */
captured: Boolean,
talking: Boolean,
vm: SessionController,
server: ServerController,
onEnableMicrophone: () -> Unit,
showChat: Boolean,
unread: Boolean,
onChat: () -> Unit,
) {
val context = LocalContext.current
val ptt = state.connected && captured && state.inputMode == Settings.InputMode.PUSH_TO_TALK
val mic = @Composable {
BarButton(
onClick = { server.setMicMuted(!state.micMuted) },
onLongClick = {
val mode = vm.toggleInputMode()
val text = if (mode == Settings.InputMode.PUSH_TO_TALK) "Switched to push-to-talk mode."
else "Switched to voice activation mode."
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
},
enabled = state.connected,
) {
if (state.micMuted) PackIcon("INPUT_MUTED", "Unmute microphone")
else PackIcon("CAPTURE", "Mute microphone")
}
}
val deafen = @Composable {
IconToggleButton(state.deafened, { server.setDeafened(it) }, enabled = state.connected) {
if (state.deafened) PackIcon("OUTPUT_MUTED", "Undeafen")
else PackIcon("PLAYBACK", "Deafen")
}
}
// Another server holds the microphone.
val activate = @Composable {
if (state.connected && state.micPermission && !state.micActive) {
IconButton(onClick = onEnableMicrophone) { PackIcon("ACTIVATE_MICROPHONE", "Speak on this server") }
}
}
val middle = @Composable {
when {
ptt -> PushToTalk(talking, vm::setPushToTalk, Modifier.fillMaxWidth())
!state.micPermission -> FilledTonalButton(onClick = onEnableMicrophone) {
PackIcon("CAPTURE", null)
Text("Use microphone", Modifier.padding(start = 8.dp), maxLines = 1)
}
}
}
val away = @Composable {
IconToggleButton(state.away, { server.setAway(it) }, enabled = state.connected) {
PackIcon("AWAY", if (state.away) "Back" else "Away", Modifier.alpha(if (state.away) 1f else 0.5f))
}
}
val chat = @Composable {
IconToggleButton(showChat, { onChat() }) {
BadgedBox(badge = { if (unread && !showChat) Badge() }) {
PackIcon(listOf("MESSAGES", "CHANNEL_CHAT"), if (showChat) "Channels" else "Chat",
Modifier.alpha(if (showChat) 1f else 0.7f))
}
}
}
Surface(tonalElevation = 3.dp) {
// The same places whatever shows: sides of three buttons each keep the talk button centred.
Row(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically) {
Row(Modifier.width(SIDE), verticalAlignment = Alignment.CenterVertically) {
Slot { mic() }; Slot { deafen() }; Slot { activate() }
}
Box(Modifier.weight(1f).height(48.dp), contentAlignment = Alignment.Center) { middle() }
Row(Modifier.width(SIDE), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) {
Slot { away() }; Slot { chat() }
}
}
}
}
private val SLOT = 48.dp
private val SIDE = SLOT * 3
/** One button's place in the bar, kept whether or not the button shows. */
@Composable
private fun Slot(content: @Composable () -> Unit) {
Box(Modifier.size(SLOT), contentAlignment = Alignment.Center) { content() }
}
/** An icon button that also answers a long press. */
@Composable
private fun BarButton(onClick: () -> Unit, onLongClick: () -> Unit, enabled: Boolean, content: @Composable () -> Unit) {
Box(
Modifier
.minimumInteractiveComponentSize()
.size(40.dp)
.clip(CircleShape)
.combinedClickable(enabled = enabled, role = Role.Button, onLongClick = onLongClick, onClick = onClick)
.alpha(if (enabled) 1f else 0.38f),
contentAlignment = Alignment.Center,
) { content() }
}
@Composable
private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
Surface(
modifier
.height(48.dp)
.pointerInput(Unit) {
detectTapGestures(onPress = {
onPressed(true)
try {
awaitRelease()
} finally {
onPressed(false)
}
})
},
shape = RoundedCornerShape(24.dp),
color = if (talking) StatusColors.talking else colors.primaryContainer,
contentColor = if (talking) Color.White else colors.onPrimaryContainer,
) {
Box(contentAlignment = Alignment.Center) {
Text(if (talking) "Talking…" else "Hold to talk", style = MaterialTheme.typography.titleMedium)
}
}
}
/** The server's own icon, else the pack's server icon. */
@Composable
private fun ServerLogo(server: ServerController, modifier: Modifier = Modifier) {
val tree by server.tree.collectAsStateWithLifecycle()
val version by server.images.version.collectAsStateWithLifecycle()
val icon = remember(tree.serverIconId, version) { server.images.icon(tree.serverIconId) }
if (icon != null) Image(icon, null, modifier.size(24.dp))
else PackIcon("SERVER_GREEN", null, modifier)
}
/** The server on screen, opening onto the others and connecting to another. */
@Composable
private fun ServerSwitcher(vm: SessionController, server: ServerController, state: SessionState, onConnectOther: () -> Unit) {
val servers by vm.servers.collectAsStateWithLifecycle()
var open by remember { mutableStateOf(false) }
Box(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { open = true },
verticalAlignment = Alignment.CenterVertically) {
ServerLogo(server, Modifier.padding(end = 12.dp))
Column(Modifier.weight(1f)) {
Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!state.connected) {
Text(state.status, style = MaterialTheme.typography.bodySmall, maxLines = 1,
overflow = TextOverflow.Ellipsis)
}
}
Icon(Icons.Filled.ArrowDropDown, "Servers")
}
DropdownMenu(open, onDismissRequest = { open = false }) {
for (other in servers) {
key(other.id) {
val otherState by other.state.collectAsStateWithLifecycle()
DropdownMenuItem(
text = { Text(otherState.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
onClick = { open = false; vm.select(other) },
leadingIcon = {
ServerLogo(other, Modifier.alpha(if (otherState.connected) 1f else 0.5f))
},
trailingIcon = if (otherState.micActive) ({ PackIcon("ACTIVATE_MICROPHONE", "Microphone") }) else null,
colors = if (other == server) MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.primary)
else MenuDefaults.itemColors(),
)
}
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Connect…") },
onClick = { open = false; onConnectOther() },
leadingIcon = { PackIcon("CONNECT", null) },
)
DropdownMenuItem(
text = { Text("Disconnect from current") },
onClick = { open = false; server.disconnect() },
leadingIcon = { PackIcon("DISCONNECT", null) },
)
}
}
}

View File

@@ -0,0 +1,98 @@
package com.ts3client.android.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.IconTheme
import com.ts3client.android.SessionController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private enum class SettingsPage { SOUND, DESIGN, IDENTITIES, MY_TEAMSPEAK }
/**
* The settings, by category. Every change is saved and put into effect at once,
* as there is no dialog to confirm.
*/
@Composable
fun SettingsScreen(vm: SessionController, onContacts: () -> Unit, onBack: () -> Unit) {
var page by rememberSaveable { mutableStateOf<SettingsPage?>(null) }
when (page) {
SettingsPage.SOUND -> return SoundSettingsScreen(vm, onBack = { page = null })
SettingsPage.DESIGN -> return DesignScreen(vm, onBack = { page = null })
SettingsPage.IDENTITIES -> return IdentitiesScreen(vm.identities, onBack = { page = null })
SettingsPage.MY_TEAMSPEAK -> return MyTeamSpeakScreen(vm, onBack = { page = null })
null -> {}
}
SubScreen("Settings", onBack) { padding ->
Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) {
LinkRow("Contacts", "Friends and blocked users", "CONTACT", onContacts)
LinkRow("Sound Settings", "Devices, transmission, voice activation, processing, volume", "PLAYBACK") {
page = SettingsPage.SOUND
}
LinkRow("Design", "Icon pack, country flags, description pictures", "GUISETUP") {
page = SettingsPage.DESIGN
}
LinkRow("Identities", "Create, import, export, security level",
"IDENTITY_MANAGER") { page = SettingsPage.IDENTITIES }
LinkRow("myTeamSpeak",
if (vm.myTeamSpeak.isSignedIn) "Signed in as ${vm.myTeamSpeak.username()}"
else "Sign in to bring over your synchronised bookmarks and identities",
"MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK }
}
}
}
@Composable
private fun DesignScreen(vm: SessionController, onBack: () -> Unit) {
SubScreen("Design", onBack) { padding ->
Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) {
IconPackPicker(vm.icons)
var flags by remember { mutableStateOf(vm.settings.countryFlags) }
Toggle("Show country flags in the channel tree", flags) {
flags = it
vm.setCountryFlags(it)
}
var pictures by remember { mutableStateOf(vm.settings.descriptionPictures) }
Toggle("Show pictures in descriptions", pictures) {
pictures = it
vm.setDescriptionPictures(it)
}
}
}
}
/** Chooses the icon pack, and brings in TeamSpeak packs from a file. */
@Composable
private fun IconPackPicker(theme: IconTheme) {
val packs by theme.packs.collectAsStateWithLifecycle()
val active by theme.active.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var failed by remember { mutableStateOf<String?>(null) }
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) scope.launch {
val pack = withContext(Dispatchers.IO) { runCatching { theme.import(uri) }.getOrNull() }
failed = if (pack == null) "That file is not an icon pack." else null
}
}
Picker("Icon pack", packs.map { it to it.name() }, active) { theme.select(it) }
LinkRow("Import icon pack", "A TeamSpeak 3 pack (.zip), such as those in the gfx folder of a TeamSpeak 3 install") {
importer.launch(arrayOf("application/zip", "application/octet-stream"))
}
failed?.let { Hint(it) }
}

View File

@@ -0,0 +1,175 @@
package com.ts3client.android.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** The width a segment needs for a label of a word or two; narrower screens get a list instead. */
private val SEGMENT_WIDTH = 110.dp
/** A full-screen page with a back arrow; the system back gesture leaves it the same way. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SubScreen(
title: String,
onBack: () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
snackbar: SnackbarHostState? = null,
content: @Composable (PaddingValues) -> Unit,
) {
BackHandler(onBack = onBack)
Scaffold(
snackbarHost = { snackbar?.let { SnackbarHost(it) } },
topBar = {
TopAppBar(
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
actions = actions,
)
},
content = content,
)
}
/** A row that opens another page, or does one thing when tapped. */
@Composable
internal fun LinkRow(title: String, summary: String?, iconKey: String? = null, onClick: () -> Unit) {
ListItem(
headlineContent = { Text(title) },
supportingContent = summary?.let { { Text(it) } },
leadingContent = iconKey?.let { { PackIcon(it, null) } },
modifier = Modifier.clickable(onClick = onClick),
)
}
@Composable
internal fun Section(title: String) {
Text(title, Modifier.padding(start = 16.dp, top = 24.dp, bottom = 4.dp),
style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
}
@Composable
internal fun Choice(label: String, selected: Boolean, onSelect: () -> Unit) {
Row(Modifier.fillMaxWidth().clickable(onClick = onSelect).padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected, onClick = onSelect)
Text(label, Modifier.padding(start = 8.dp))
}
}
/** One of [options] as a row of segments, or as radio buttons where the segments would not fit. */
@Composable
internal fun <T> Segments(options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
if (maxWidth - 32.dp < SEGMENT_WIDTH * options.size) {
Column { for ((value, label) in options) Choice(label, value == selected) { onSelect(value) } }
return@BoxWithConstraints
}
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
options.forEachIndexed { index, (value, label) ->
SegmentedButton(
selected = value == selected,
onClick = { onSelect(value) },
shape = SegmentedButtonDefaults.itemShape(index, options.size),
) { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) }
}
}
}
}
@Composable
internal fun Toggle(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
Row(Modifier.fillMaxWidth().clickable { onChange(!checked) }.padding(horizontal = 16.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically) {
Text(label, Modifier.weight(1f))
Switch(checked, onChange)
}
}
@Composable
internal fun Hint(text: String) {
Text(text, Modifier.padding(horizontal = 16.dp, vertical = 2.dp),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
/** One of [options], shown as its label and picked from a menu. */
@Composable
internal fun <T> Picker(label: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
var open by remember { mutableStateOf(false) }
Box {
Column(Modifier.fillMaxWidth().clickable { open = true }.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(label)
Text(options.firstOrNull { it.first == selected }?.second ?: "", style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
DropdownMenu(open, { open = false }) {
for ((value, text) in options) {
DropdownMenuItem({ Text(text) }, onClick = {
open = false
onSelect(value)
})
}
}
}
}
/** A slider that applies its value once let go of. */
@Composable
internal fun Level(
label: String,
value: Float,
range: ClosedFloatingPointRange<Float>,
format: (Float) -> String,
steps: Int = 0,
onChange: (Float) -> Unit = {},
onDone: (Float) -> Unit,
) {
var current by remember(value) { mutableFloatStateOf(value) }
Column(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
Row {
Text(label, Modifier.weight(1f))
Text(format(current), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Slider(current, {
current = it
onChange(it)
}, valueRange = range, steps = steps, onValueChangeFinished = { onDone(current) })
}
}

View File

@@ -0,0 +1,221 @@
package com.ts3client.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import com.ts3client.android.ChannelItem
import com.ts3client.android.ClientItem
import com.ts3client.contacts.ContactType
import com.ts3client.net.Details
import kotlin.math.roundToInt
/** What can be done with a client: the private chat, a poke, a move, their volume, filing them as a contact. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ClientSheet(
client: ClientItem,
details: Details?,
onLink: (String) -> Unit,
away: Boolean,
initialVolume: Double,
onDismiss: () -> Unit,
onMessage: () -> Unit,
onPoke: (String) -> Unit,
onVolume: (Double) -> Unit,
onAway: (Boolean) -> Unit,
onAddContact: (ContactType) -> Unit,
onEditContact: () -> Unit,
/** Null when the client cannot be moved. */
onMove: (() -> Unit)?,
/** Null when the client cannot be moved, or is in our channel already. */
onMoveToOwnChannel: (() -> Unit)?,
/** Null when the sheet was opened from the tree. */
onFindInTree: (() -> Unit)?,
/** Kicks, bans and groups, as far as we may use them. */
moderation: @Composable () -> Unit = {},
) {
var poking by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) {
Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
StatusIcon(client.status)
Column(Modifier.weight(1f)) {
// The country's flag goes before the name, as in TeamSpeak's info frame.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CountryFlag(client.country)
Text(client.name, style = MaterialTheme.typography.titleLarge)
}
if (client.name != client.nickname) Text(client.nickname, style = MaterialTheme.typography.bodyMedium)
if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium)
}
val viewer = LocalPictureViewer.current
val images = LocalServerImages.current
Avatar(client.uniqueId, client.avatarFlag, Modifier.clickable(enabled = viewer != null && images != null) {
viewer?.invoke(ViewedPicture("avatar_${client.nickname}", ownAvatar = client.self) {
images?.avatarData(client.uniqueId, client.avatarFlag)
})
})
}
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
onFindInTree?.let { Action("CHANNEL_SWITCH", "Find client in channel tree", onClick = it) }
if (client.self) {
Action("AWAY", if (away) "Back" else "Away") { onAway(!away) }
moderation()
} else {
Action("PLAYER_CHAT", "Send message", onClick = onMessage)
Action("POKE", "Poke") { poking = true }
onMove?.let { Action("CHANNEL_SWITCH", "Move to channel…", onClick = it) }
onMoveToOwnChannel?.let { Action("MOVE_CLIENT_TO_OWN_CHANNEL", "Move to own channel", onClick = it) }
moderation()
if (client.contactType == null) {
Row {
Action("ADD_FRIEND", "Add as friend", Modifier.weight(1f)) { onAddContact(ContactType.FRIEND) }
Action("ADD_FOE", "Block", Modifier.weight(1f)) { onAddContact(ContactType.BLOCKED) }
}
} else {
Action("EDIT_FRIEND_FOE_STATUS", "Edit contact", onClick = onEditContact)
}
var volume by remember { mutableFloatStateOf(initialVolume.toFloat()) }
Text("Volume: ${if (volume > 0) "+" else ""}${volume.roundToInt()} dB",
Modifier.padding(start = 24.dp, top = 8.dp), style = MaterialTheme.typography.bodyMedium)
Slider(
volume, { volume = it.roundToInt().toFloat() },
Modifier.padding(horizontal = 24.dp),
valueRange = -30f..20f,
onValueChangeFinished = { onVolume(volume.toDouble()) },
)
}
}
}
if (poking) {
TextPrompt(
title = "Poke ${client.name}",
label = "Message",
confirm = "Poke",
onDismiss = { poking = false },
onConfirm = { poking = false; onPoke(it) },
)
}
}
/** What can be done with a channel: join it, and whether to hear who is in it. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelSheet(
channel: ChannelItem,
details: Details?,
onLink: (String) -> Unit,
current: Boolean,
onDismiss: () -> Unit,
onJoin: (String?) -> Unit,
onSubscribe: (Boolean) -> Unit,
) {
var askPassword by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) {
Text(channel.name, Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
style = MaterialTheme.typography.titleLarge)
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
if (!current) {
Action("CHANNEL_SWITCH", "Join") {
if (channel.locked) askPassword = true else onJoin(null)
}
}
if (channel.subscribed) {
Action("UNSUBSCRIBE_FROM_CHANNEL", "Unsubscribe") { onSubscribe(false) }
} else {
Action("SUBSCRIBE_TO_CHANNEL", "Subscribe") { onSubscribe(true) }
}
}
}
if (askPassword) {
TextPrompt(
title = "Join ${channel.name}",
label = "Channel password",
confirm = "Join",
password = true,
onDismiss = { askPassword = false },
onConfirm = { askPassword = false; onJoin(it) },
)
}
}
/** The server's details: its version, who is on it, its welcome message. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServerSheet(details: Details, onLink: (String) -> Unit, onDismiss: () -> Unit, onBanList: (() -> Unit)?) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) {
Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PackIcon("SERVER_GREEN", null)
Text(details.title(), style = MaterialTheme.typography.titleLarge)
}
DetailsSection(details, onLink)
onBanList?.let { Action("BAN_LIST", "Ban list", onClick = it) }
}
}
}
@Composable
internal fun Action(iconKey: String, label: String, modifier: Modifier = Modifier.fillMaxWidth(), onClick: () -> Unit) {
ListItem(
headlineContent = { Text(label) },
leadingContent = { PackIcon(iconKey, null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = modifier.clickable(onClick = onClick).padding(horizontal = 8.dp),
)
}
/** Asks for one line of text. */
@Composable
fun TextPrompt(
title: String,
label: String,
confirm: String,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
initial: String = "",
password: Boolean = false,
) {
var text by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
OutlinedTextField(text, { text = it }, label = { Text(label) }, singleLine = true,
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None)
},
confirmButton = { TextButton(onClick = { onConfirm(text) }) { Text(confirm) } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}

View File

@@ -0,0 +1,260 @@
package com.ts3client.android.ui
import android.content.Intent
import android.net.Uri
import android.provider.Settings.ACTION_MANAGE_OVERLAY_PERMISSION
import android.provider.Settings.canDrawOverlays
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.MicLevel
import com.ts3client.android.SessionController
import com.ts3client.android.audio.AudioRoute
import com.ts3client.audio.AudioDevice
import com.ts3client.audio.InputLevel
import com.ts3client.config.Settings
import kotlin.math.roundToInt
private val DENOISE_LEVELS = listOf("Off", "6 dB", "12 dB", "18 dB", "21 dB")
/** Audio devices, transmission, voice activation, processing and playback. */
@Composable
fun SoundSettingsScreen(vm: SessionController, onBack: () -> Unit) {
val settings = vm.settings
fun change(edit: Settings.() -> Unit) {
settings.edit()
vm.applySettings()
}
var inputMode by remember { mutableStateOf(settings.inputMode) }
var vadMode by remember { mutableStateOf(settings.vadMode) }
var agc by remember { mutableStateOf(settings.agc) }
var mutedTalkWarning by remember { mutableStateOf(settings.mutedTalkWarning) }
var threshold by remember { mutableFloatStateOf(settings.vadThresholdDb.toFloat()) }
var inputDevice by remember { mutableStateOf(settings.inputDevice) }
var outputDevice by remember { mutableStateOf(settings.outputDevice) }
val inputs by vm.inputDevices.collectAsStateWithLifecycle()
val outputs by vm.outputDevices.collectAsStateWithLifecycle()
val route by vm.route.collectAsStateWithLifecycle()
val level by vm.micLevel.collectAsStateWithLifecycle()
val packs by vm.soundPacks.collectAsStateWithLifecycle()
var pack by remember { mutableStateOf(vm.soundPack?.id()) }
LifecycleStartEffect(Unit) {
vm.watchLevel(true)
onStopOrDispose { vm.watchLevel(false) }
}
SubScreen("Sound Settings", onBack) { padding ->
Column(Modifier.padding(padding).verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
Section("Devices")
DevicePicker("Microphone", inputs, inputDevice) {
inputDevice = it
change { this.inputDevice = it }
}
DevicePicker("Speaker", outputs, outputDevice) {
outputDevice = it
change { this.outputDevice = it }
}
Picker(
"Microphone preset",
INPUT_PRESETS.map { (preset, label) ->
val unsupported = preset == AudioRoute.InputPreset.UNPROCESSED && !vm.unprocessedSupported
preset to if (unsupported) "$label (not supported here)" else label
},
route.inputPreset(),
) { vm.setRoute(AudioRoute(it, route.usage())) }
Hint(PRESET_HINTS.getValue(route.inputPreset()))
Text("Play voices", Modifier.padding(start = 16.dp, top = 8.dp))
for ((usage, label) in USAGES) {
Choice(label, route.usage() == usage) { vm.setRoute(AudioRoute(route.inputPreset(), usage)) }
}
Section("Transmission")
for ((mode, label) in INPUT_MODES) {
Choice(label, inputMode == mode) {
inputMode = mode
change { this.inputMode = mode }
}
}
if (inputMode == Settings.InputMode.PUSH_TO_TALK) FloatingButtonToggle(vm)
val gated = inputMode == Settings.InputMode.VOICE_ACTIVATION && vadMode != Settings.VadMode.AUTOMATIC
LevelMeter(level, threshold.takeIf { gated })
if (!level.live) {
Hint(if (vm.hasMicPermission()) "The microphone is not available right now." else "Allow the microphone to see its level.")
}
if (inputMode == Settings.InputMode.VOICE_ACTIVATION) {
Section("Voice activation")
for ((mode, label) in VAD_MODES) {
Choice(label, vadMode == mode) {
vadMode = mode
change { this.vadMode = mode }
}
}
if (vadMode != Settings.VadMode.AUTOMATIC) {
Level("Volume threshold", settings.vadThresholdDb.toFloat(), -50f..50f, { "${it.roundToInt()}" },
onChange = { threshold = it }) {
change { vadThresholdDb = it.roundToInt().toDouble() }
}
}
if (vadMode != Settings.VadMode.VOLUME_GATE) {
Level("Speech likelihood", settings.speechThreshold.toFloat(), 0f..1f, { "${(it * 100).roundToInt()} %" }) {
change { speechThreshold = it.toDouble() }
}
}
}
Section("Processing")
val suppression = if (settings.denoise) settings.denoiserLevel + 1 else 0
Level("Noise suppression", suppression.toFloat(), 0f..DENOISE_LEVELS.lastIndex.toFloat(),
{ DENOISE_LEVELS[it.roundToInt()] }, steps = DENOISE_LEVELS.lastIndex - 1) {
val step = it.roundToInt()
change {
denoise = step > 0
if (step > 0) denoiserLevel = step - 1
}
}
Toggle("Automatic gain control", agc) {
agc = it
change { this.agc = it }
}
if (agc) {
Level("Boost quiet speech", settings.agcBoostDb.toFloat(), 0f..Settings.MAX_AGC_BOOST_DB.toFloat(),
{ "+${it.roundToInt()} dB" }, steps = Settings.MAX_AGC_BOOST_DB - 1) {
change { agcBoostDb = it.roundToInt() }
}
}
Section("Playback")
Level("Volume", settings.outputVolume.toFloat(), 0f..2f, { "${(it * 100).roundToInt()} %" }) {
change { outputVolume = it.toDouble() }
}
Section("Notification sounds")
if (packs.isEmpty()) {
Hint("This build of the app came without sound packs.")
} else {
Picker("Sound pack", packs.map { it.id() to it.name() }, pack) { id ->
packs.firstOrNull { it.id() == id }?.let(vm::setSoundPack)
pack = id
}
}
Level("Volume", settings.soundVolume.toFloat(), 0f..1f, { "${(it * 100).roundToInt()} %" }) {
change { soundVolume = it.toDouble() }
}
Toggle("Beep when talking while muted", mutedTalkWarning) {
mutedTalkWarning = it
change { this.mutedTalkWarning = it }
}
}
}
}
private val INPUT_MODES = listOf(
Settings.InputMode.VOICE_ACTIVATION to "Voice activation",
Settings.InputMode.PUSH_TO_TALK to "Push to talk",
Settings.InputMode.CONTINUOUS to "Continuous",
)
private val INPUT_PRESETS = listOf(
AudioRoute.InputPreset.VOICE_RECOGNITION to "Voice recognition",
AudioRoute.InputPreset.VOICE_COMMUNICATION to "Voice communication",
AudioRoute.InputPreset.UNPROCESSED to "Unprocessed",
AudioRoute.InputPreset.VOICE_PERFORMANCE to "Voice performance",
AudioRoute.InputPreset.GENERIC to "Generic",
AudioRoute.InputPreset.CAMCORDER to "Camcorder",
)
private val PRESET_HINTS = mapOf(
AudioRoute.InputPreset.VOICE_RECOGNITION to "Little or no processing by the phone; the client's own does the rest.",
AudioRoute.InputPreset.VOICE_COMMUNICATION to
"The phone's call processing: echo cancellation, noise suppression and gain. Works best with voices played as a call.",
AudioRoute.InputPreset.UNPROCESSED to "The raw microphone signal.",
AudioRoute.InputPreset.VOICE_PERFORMANCE to "Low latency and no processing, meant for live performance.",
AudioRoute.InputPreset.GENERIC to "The phone's default microphone source, with whatever it applies to it.",
AudioRoute.InputPreset.CAMCORDER to "Tuned for recording video, often with the rear microphone.",
)
private val USAGES = listOf(
AudioRoute.Usage.MEDIA to "As media (Bluetooth stays in high quality)",
AudioRoute.Usage.VOICE_COMMUNICATION to "As a call (lets the phone cancel echo)",
)
private val VAD_MODES = listOf(
Settings.VadMode.AUTOMATIC to "Automatic",
Settings.VadMode.VOLUME_GATE to "Volume gate",
Settings.VadMode.HYBRID to "Hybrid",
)
/** The push-to-talk button over other apps, which first needs the user to allow the app to draw there. */
@Composable
private fun FloatingButtonToggle(vm: SessionController) {
val context = LocalContext.current
val enabled by vm.pttOverlay.collectAsStateWithLifecycle()
val allow = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (canDrawOverlays(context)) vm.setPttOverlay(true)
}
Toggle("Floating button over other apps", enabled && canDrawOverlays(context)) { on ->
if (!on || canDrawOverlays(context)) vm.setPttOverlay(on)
else allow.launch(Intent(ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${context.packageName}")))
}
Hint("Hold it to talk. Double-tap and keep holding to drag it elsewhere.")
}
/** Picks a device by its settings id; one that is not plugged in now still shows as chosen. */
@Composable
private fun DevicePicker(label: String, devices: List<AudioDevice>, selected: String, onSelect: (String) -> Unit) {
val options = devices.map { it.id() to it.label() }.let { list ->
if (list.any { it.first == selected }) list else list + (selected to "Not connected")
}
Picker(label, options, selected, onSelect)
}
/** The microphone level, lit while it would be sent, with the volume gate's threshold marked. */
@Composable
private fun LevelMeter(level: MicLevel, thresholdDb: Float?) {
val track = MaterialTheme.colorScheme.surfaceVariant
val fill = if (level.transmitting) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
val marker = MaterialTheme.colorScheme.onSurface
fun fraction(db: Double) =
((db - InputLevel.MIN_DB) / (InputLevel.MAX_DB - InputLevel.MIN_DB)).toFloat().coerceIn(0f, 1f)
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Row {
Text("Microphone level", Modifier.weight(1f))
if (level.live) Text("${level.db.roundToInt()}", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Canvas(Modifier.fillMaxWidth().padding(top = 6.dp).height(12.dp)) {
val radius = CornerRadius(size.height / 2)
drawRoundRect(track, cornerRadius = radius)
if (level.live) {
drawRoundRect(fill, size = Size(size.width * fraction(level.db), size.height), cornerRadius = radius)
}
if (thresholdDb != null) {
val x = size.width * fraction(thresholdDb.toDouble())
drawLine(marker, Offset(x, -2.dp.toPx()), Offset(x, size.height + 2.dp.toPx()), strokeWidth = 2.dp.toPx())
}
}
}
}

View File

@@ -0,0 +1,38 @@
package com.ts3client.android.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
/** Colours that mean the same in either theme, as TeamSpeak uses them. */
object StatusColors {
val talking = Color(0xFF2E9BF0)
val muted = Color(0xFFD9443A)
val away = Color(0xFF9E8A2E)
val friend = Color(0xFF3A9D4A)
val blocked = Color(0xFFD9443A)
}
/** Links in chat and descriptions: blue, as links are, whatever the wallpaper's colours. */
@Composable
fun linkColor(): Color = if (isSystemInDarkTheme()) Color(0xFF7AB4FF) else Color(0xFF1A62D0)
/** Behind every match of the find bar's query, as on the desktop. */
@Composable
fun findMatchColor(): Color = if (isSystemInDarkTheme()) Color(0xFF5C4E1A) else Color(0xFFFFEBA3)
/** Behind the match the find bar is on. */
@Composable
fun findCurrentColor(): Color = if (isSystemInDarkTheme()) Color(0xFF9C6A12) else Color(0xFFFFB84D)
/** The system's own (wallpaper) colours, which every supported Android version offers. */
@Composable
fun Ts3Theme(content: @Composable () -> Unit) {
val context = LocalContext.current
val colors = if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
MaterialTheme(colorScheme = colors, content = content)
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TS3J</string>
<string name="connection_channel">Connection</string>
<string name="messages_channel">Private messages</string>
<string name="pokes_channel">Pokes</string>
</resources>

View File

@@ -0,0 +1,42 @@
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "com.ts3client.android.audio"
compileSdk = 37
ndkVersion = "30.0.16248370"
defaultConfig {
minSdk = 33
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}
externalNativeBuild {
cmake {
arguments += "-DANDROID_STL=c++_shared"
}
}
}
buildFeatures {
prefab = true
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "4.1.2"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
api(project(":core"))
implementation(libs.oboe)
}

View File

@@ -0,0 +1,22 @@
cmake_minimum_required(VERSION 3.22)
project(ts3audio LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
find_package(oboe REQUIRED CONFIG)
# libopus from its release tarball, built static into our one library.
include(FetchContent)
FetchContent_Declare(opus
URL https://github.com/xiph/opus/releases/download/v1.5.2/opus-1.5.2.tar.gz
URL_HASH SHA256=65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1)
set(OPUS_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
set(OPUS_BUILD_TESTING OFF CACHE BOOL "" FORCE)
set(OPUS_BUILD_SHARED_LIBRARY OFF CACHE BOOL "" FORCE)
set(OPUS_INSTALL_PKG_CONFIG_MODULE OFF CACHE BOOL "" FORCE)
set(OPUS_INSTALL_CMAKE_CONFIG_MODULE OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(opus)
add_library(ts3audio SHARED audio_line.cpp opus_jni.cpp)
target_link_libraries(ts3audio PRIVATE oboe::oboe opus log)
target_compile_options(ts3audio PRIVATE -Wall -Wextra)

View File

@@ -0,0 +1,274 @@
// An Oboe stream behind the blocking read/write interface of AudioCapture and AudioPlayback.
//
// The real-time callback never waits and never calls into Java: it only moves samples
// between the device and a lock-free ring, which the Java side fills or drains at its own
// pace, blocking while the ring is full (playback) or short (capture).
#include <jni.h>
#include <oboe/Oboe.h>
#include <android/log.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstring>
#include <memory>
#include <mutex>
#include <thread>
#include <vector>
namespace {
constexpr int32_t kSampleRate = 48000;
constexpr int32_t kFrameSize = 960; // 20 ms
// Capture keeps some slack for a Java thread that is briefly late; playback keeps little,
// since everything queued is latency.
constexpr int32_t kCaptureFrames = 8;
constexpr int32_t kPlaybackFrames = 3;
constexpr auto kPollInterval = std::chrono::milliseconds(1);
const char *kTag = "ts3audio";
/** Single-producer, single-consumer ring of 16-bit samples. */
class SampleRing {
public:
explicit SampleRing(size_t capacity) : buffer_(capacity) {}
size_t available() const {
return head_.load(std::memory_order_acquire) - tail_.load(std::memory_order_acquire);
}
size_t space() const { return buffer_.size() - available(); }
/** Producer side: stores as much of {@code data} as fits. */
size_t write(const int16_t *data, size_t count) {
size_t head = head_.load(std::memory_order_relaxed);
size_t tail = tail_.load(std::memory_order_acquire);
size_t n = std::min(count, buffer_.size() - (head - tail));
for (size_t i = 0; i < n; i++) buffer_[(head + i) % buffer_.size()] = data[i];
head_.store(head + n, std::memory_order_release);
return n;
}
/** Consumer side: takes up to {@code count} samples. */
size_t read(int16_t *data, size_t count) {
size_t tail = tail_.load(std::memory_order_relaxed);
size_t head = head_.load(std::memory_order_acquire);
size_t n = std::min(count, head - tail);
for (size_t i = 0; i < n; i++) data[i] = buffer_[(tail + i) % buffer_.size()];
tail_.store(tail + n, std::memory_order_release);
return n;
}
private:
std::vector<int16_t> buffer_;
std::atomic<size_t> head_{0};
std::atomic<size_t> tail_{0};
};
class AudioLine : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback {
public:
/** {@code preset} is the input preset of a capture line, {@code usage} a playback line's usage. */
AudioLine(bool input, int32_t deviceId, int32_t channels, int32_t preset, int32_t usage)
: input_(input), deviceId_(deviceId), channels_(channels), preset_(preset), usage_(usage),
ring_(static_cast<size_t>(kFrameSize) * channels * (input ? kCaptureFrames : kPlaybackFrames)) {}
int32_t channels() const { return channels_; }
oboe::Result open() {
oboe::AudioStreamBuilder builder;
builder.setDirection(input_ ? oboe::Direction::Input : oboe::Direction::Output)
->setSharingMode(oboe::SharingMode::Shared)
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setFormat(oboe::AudioFormat::I16)
->setFormatConversionAllowed(true)
->setChannelCount(channels_)
->setChannelConversionAllowed(true)
->setSampleRate(kSampleRate)
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Medium)
->setDataCallback(this)
->setErrorCallback(this);
int32_t deviceId = deviceId_.load();
if (deviceId > 0) builder.setDeviceId(deviceId);
if (input_) {
builder.setInputPreset(static_cast<oboe::InputPreset>(preset_.load()));
} else {
builder.setUsage(static_cast<oboe::Usage>(usage_.load()))->setContentType(oboe::ContentType::Speech);
}
std::shared_ptr<oboe::AudioStream> stream;
oboe::Result result = builder.openStream(stream);
if (result != oboe::Result::OK) return result;
std::lock_guard<std::mutex> lock(mutex_);
if (closing_.load()) { // closed while a reopen was under way
stream->close();
return oboe::Result::ErrorClosed;
}
if (stream_) stream_->close(); // a concurrent reopen got there first
stream_ = stream;
if (started_) result = stream_->requestStart();
return result;
}
oboe::Result start() {
std::lock_guard<std::mutex> lock(mutex_);
started_ = true;
return stream_ ? stream_->requestStart() : oboe::Result::ErrorClosed;
}
/** Moves the line to another device or configuration; the ring and whoever blocks on it stay. */
oboe::Result reopen(int32_t deviceId, int32_t preset, int32_t usage) {
{
std::lock_guard<std::mutex> lock(mutex_);
if (closing_.load()) return oboe::Result::ErrorClosed;
if (stream_) {
stream_->stop();
stream_->close();
stream_.reset();
}
}
deviceId_.store(deviceId);
preset_.store(preset);
usage_.store(usage);
return open();
}
/** Blocks until {@code count} samples are captured; fewer only once closing. */
size_t read(int16_t *data, size_t count) {
size_t done = 0;
while (done < count && !closing_.load()) {
done += ring_.read(data + done, count - done);
if (done < count) std::this_thread::sleep_for(kPollInterval);
}
return done;
}
/** Blocks until all samples are queued for playback, or the line closes. */
void write(const int16_t *data, size_t count) {
size_t done = 0;
while (done < count && !closing_.load()) {
done += ring_.write(data + done, count - done);
if (done < count) std::this_thread::sleep_for(kPollInterval);
}
}
void close() {
closing_.store(true);
std::lock_guard<std::mutex> lock(mutex_);
if (stream_) {
stream_->stop();
stream_->close();
stream_.reset();
}
}
oboe::DataCallbackResult onAudioReady(oboe::AudioStream *, void *audioData, int32_t numFrames) override {
auto *samples = static_cast<int16_t *>(audioData);
size_t count = static_cast<size_t>(numFrames) * channels_;
if (input_) {
ring_.write(samples, count); // a reader that fell behind loses the newest audio
} else {
size_t played = ring_.read(samples, count);
if (played < count) std::memset(samples + played, 0, (count - played) * sizeof(int16_t));
}
return oboe::DataCallbackResult::Continue;
}
/** The device went away (headset unplugged, route changed): follow onto the new one. */
void onErrorAfterClose(oboe::AudioStream *, oboe::Result error) override {
if (closing_.load()) return;
__android_log_print(ANDROID_LOG_INFO, kTag, "stream closed (%s), reopening",
oboe::convertToText(error));
deviceId_.store(0);
open();
}
private:
const bool input_;
std::atomic<int32_t> deviceId_;
const int32_t channels_;
std::atomic<int32_t> preset_;
std::atomic<int32_t> usage_;
SampleRing ring_;
std::atomic<bool> closing_{false};
std::mutex mutex_;
std::shared_ptr<oboe::AudioStream> stream_;
bool started_ = false; // guarded by mutex_
};
AudioLine *line(jlong handle) {
return reinterpret_cast<AudioLine *>(handle);
}
thread_local std::vector<int16_t> scratch;
} // namespace
extern "C" {
JNIEXPORT jlong JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeOpen(JNIEnv *env, jclass, jboolean input, jint deviceId,
jint channels, jint preset, jint usage) {
auto *created = new AudioLine(input, deviceId, channels, preset, usage);
oboe::Result result = created->open();
if (result != oboe::Result::OK) {
delete created;
env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), oboe::convertToText(result));
return 0;
}
return reinterpret_cast<jlong>(created);
}
JNIEXPORT jint JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeChannels(JNIEnv *, jclass, jlong handle) {
return line(handle)->channels();
}
JNIEXPORT void JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeStart(JNIEnv *env, jclass, jlong handle) {
oboe::Result result = line(handle)->start();
if (result != oboe::Result::OK) {
env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), oboe::convertToText(result));
}
}
JNIEXPORT void JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeReopen(JNIEnv *env, jclass, jlong handle, jint deviceId,
jint preset, jint usage) {
oboe::Result result = line(handle)->reopen(deviceId, preset, usage);
if (result != oboe::Result::OK && result != oboe::Result::ErrorClosed) {
env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), oboe::convertToText(result));
}
}
JNIEXPORT jint JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeRead(JNIEnv *env, jclass, jlong handle, jbyteArray buffer,
jint offset, jint length) {
size_t samples = static_cast<size_t>(length) / sizeof(int16_t);
scratch.resize(samples);
size_t read = line(handle)->read(scratch.data(), samples);
env->SetByteArrayRegion(buffer, offset, static_cast<jsize>(read * sizeof(int16_t)),
reinterpret_cast<const jbyte *>(scratch.data()));
return static_cast<jint>(read * sizeof(int16_t));
}
JNIEXPORT void JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeWrite(JNIEnv *env, jclass, jlong handle, jbyteArray buffer,
jint offset, jint length) {
size_t samples = static_cast<size_t>(length) / sizeof(int16_t);
scratch.resize(samples);
env->GetByteArrayRegion(buffer, offset, static_cast<jsize>(samples * sizeof(int16_t)),
reinterpret_cast<jbyte *>(scratch.data()));
line(handle)->write(scratch.data(), samples);
}
JNIEXPORT void JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeClose(JNIEnv *, jclass, jlong handle) {
line(handle)->close();
}
JNIEXPORT void JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeDelete(JNIEnv *, jclass, jlong handle) {
delete line(handle);
}
} // extern "C"

View File

@@ -0,0 +1,109 @@
// libopus for NativeOpus: handles are the raw encoder/decoder pointers.
#include <jni.h>
#include <opus.h>
#include <string>
#include <vector>
namespace {
constexpr int kMaxPacketBytes = 4096;
void throwOpus(JNIEnv *env, const char *call, int error) {
std::string message = std::string(call) + " failed: " + opus_strerror(error);
env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), message.c_str());
}
OpusEncoder *encoder(jlong handle) { return reinterpret_cast<OpusEncoder *>(handle); }
OpusDecoder *decoder(jlong handle) { return reinterpret_cast<OpusDecoder *>(handle); }
} // namespace
extern "C" {
JNIEXPORT jlong JNICALL
Java_com_ts3client_android_audio_NativeOpus_encoderCreate(JNIEnv *env, jclass, jint sampleRate, jint channels,
jint application) {
int error = OPUS_OK;
OpusEncoder *created = opus_encoder_create(sampleRate, channels, application, &error);
if (error != OPUS_OK) {
throwOpus(env, "opus_encoder_create", error);
return 0;
}
return reinterpret_cast<jlong>(created);
}
JNIEXPORT void JNICALL
Java_com_ts3client_android_audio_NativeOpus_encoderCtl(JNIEnv *env, jclass, jlong handle, jint request,
jint value) {
int result = opus_encoder_ctl(encoder(handle), request, value);
if (result < 0) throwOpus(env, "opus_encoder_ctl", result);
}
JNIEXPORT jbyteArray JNICALL
Java_com_ts3client_android_audio_NativeOpus_encode(JNIEnv *env, jclass, jlong handle, jfloatArray pcm,
jint frameSize) {
unsigned char packet[kMaxPacketBytes];
jfloat *samples = env->GetFloatArrayElements(pcm, nullptr);
int length = opus_encode_float(encoder(handle), samples, frameSize, packet, kMaxPacketBytes);
env->ReleaseFloatArrayElements(pcm, samples, JNI_ABORT);
if (length < 0) {
throwOpus(env, "opus_encode_float", length);
return nullptr;
}
jbyteArray out = env->NewByteArray(length);
env->SetByteArrayRegion(out, 0, length, reinterpret_cast<const jbyte *>(packet));
return out;
}
JNIEXPORT void JNICALL
Java_com_ts3client_android_audio_NativeOpus_encoderDestroy(JNIEnv *, jclass, jlong handle) {
opus_encoder_destroy(encoder(handle));
}
JNIEXPORT jlong JNICALL
Java_com_ts3client_android_audio_NativeOpus_decoderCreate(JNIEnv *env, jclass, jint sampleRate, jint channels) {
int error = OPUS_OK;
OpusDecoder *created = opus_decoder_create(sampleRate, channels, &error);
if (error != OPUS_OK) {
throwOpus(env, "opus_decoder_create", error);
return 0;
}
return reinterpret_cast<jlong>(created);
}
/** Decodes {@code packet}, or conceals {@code frameSize} samples when it is null. */
JNIEXPORT jint JNICALL
Java_com_ts3client_android_audio_NativeOpus_decode(JNIEnv *env, jclass, jlong handle, jbyteArray packet,
jfloatArray out, jint frameSize) {
std::vector<unsigned char> data;
if (packet != nullptr) {
data.resize(static_cast<size_t>(env->GetArrayLength(packet)));
env->GetByteArrayRegion(packet, 0, static_cast<jsize>(data.size()), reinterpret_cast<jbyte *>(data.data()));
}
jfloat *samples = env->GetFloatArrayElements(out, nullptr);
int decoded = opus_decode_float(decoder(handle), packet != nullptr ? data.data() : nullptr,
static_cast<opus_int32>(data.size()), samples, frameSize, 0);
env->ReleaseFloatArrayElements(out, samples, decoded < 0 ? JNI_ABORT : 0);
if (decoded < 0) throwOpus(env, "opus_decode_float", decoded);
return decoded;
}
JNIEXPORT void JNICALL
Java_com_ts3client_android_audio_NativeOpus_decoderReset(JNIEnv *, jclass, jlong handle) {
opus_decoder_ctl(decoder(handle), OPUS_RESET_STATE);
}
JNIEXPORT void JNICALL
Java_com_ts3client_android_audio_NativeOpus_decoderDestroy(JNIEnv *, jclass, jlong handle) {
opus_decoder_destroy(decoder(handle));
}
JNIEXPORT jstring JNICALL
Java_com_ts3client_android_audio_NativeOpus_version0(JNIEnv *env, jclass) {
return env->NewStringUTF(opus_get_version_string());
}
} // extern "C"

View File

@@ -0,0 +1,60 @@
package com.ts3client.android.audio;
import android.content.Context;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.AudioIo;
import com.ts3client.audio.StreamingVoiceOutput;
import com.ts3client.audio.opus.OpusCodec;
/** Android's audio stack: Oboe for the devices, libopus built with the NDK. */
public final class AndroidAudioBackend implements AudioBackend {
private final AndroidAudioIo io;
private final OpusCodec opus = new NativeOpus();
public AndroidAudioBackend(Context context) {
this.io = new AndroidAudioIo(context.getApplicationContext());
}
@Override
public AudioIo io() {
return io;
}
/** How the lines meet Android's audio: microphone preset, and media or call playback. */
public AudioRoute route() {
return io.route();
}
/** Applies at once to the lines that are open. */
public void setRoute(AudioRoute route) {
io.setRoute(route);
}
/** Whether the device offers {@link AudioRoute.InputPreset#UNPROCESSED} truly unprocessed. */
public boolean unprocessedSupported() {
return io.unprocessedSupported();
}
/** Runs on the main thread whenever an audio device comes or goes. */
public void addDeviceListener(Runnable listener) {
io.addDeviceListener(listener);
}
@Override
public OpusCodec opus() {
return opus;
}
/** One low-latency stream for everyone, as mobile audio wants. */
@Override
public StreamingVoiceOutput.Lines outputLines() {
return StreamingVoiceOutput.Lines.SHARED;
}
@Override
public String description() {
return "Opus " + opus.version() + ", Oboe";
}
}

View File

@@ -0,0 +1,332 @@
package com.ts3client.android.audio;
import android.content.Context;
import android.media.AudioDeviceCallback;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;
import android.os.Handler;
import android.os.Looper;
import com.ts3client.audio.AudioCapture;
import com.ts3client.audio.AudioDevice;
import com.ts3client.audio.AudioIo;
import com.ts3client.audio.AudioPlayback;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* The device's microphones and speakers, opened through Oboe.
*
* <p>Android's device ids are handed out anew whenever a device appears, so a Bluetooth
* headset has another one each time it reconnects. What the settings keep is the device's
* type and address instead, which stay the same, and each open looks the id up.
*
* <p>Also owns the phone's call state: communication mode and the communication device are
* only set while a line needs them, i.e. playback as a call or a headset microphone.
*/
final class AndroidAudioIo implements AudioIo {
/** AudioDeviceInfo.TYPE_ECHO_REFERENCE, which the SDK keeps as a system API. */
private static final int TYPE_ECHO_REFERENCE = 28;
private final AudioManager audioManager;
private final Set<OboeLine> open = ConcurrentHashMap.newKeySet();
/** Reopening streams takes a moment; it happens off the caller's thread, one at a time. */
private final ExecutorService reopener = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "audio-reopen");
t.setDaemon(true);
return t;
});
private final List<Runnable> deviceListeners = new ArrayList<>();
private volatile AudioRoute route = AudioRoute.DEFAULT;
/** Whether this class put the phone into communication mode, and so must take it out. */
private boolean inCall;
/** The line being opened, which counts towards the call before it exists; null when none is. */
private String openingDevice;
private boolean openingInput;
AndroidAudioIo(Context context) {
this.audioManager = context.getSystemService(AudioManager.class);
audioManager.registerAudioDeviceCallback(new AudioDeviceCallback() {
@Override
public void onAudioDevicesAdded(AudioDeviceInfo[] added) {
returnToDevices(added);
notifyDevices();
}
@Override
public void onAudioDevicesRemoved(AudioDeviceInfo[] removed) {
notifyDevices();
}
}, new Handler(Looper.getMainLooper()));
}
// ---- devices ----
@Override
public List<AudioDevice> inputDevices() {
return devices(AudioManager.GET_DEVICES_INPUTS, true);
}
@Override
public List<AudioDevice> outputDevices() {
return devices(AudioManager.GET_DEVICES_OUTPUTS, false);
}
/** Runs on the main thread whenever a device comes or goes. */
void addDeviceListener(Runnable listener) {
synchronized (deviceListeners) {
deviceListeners.add(listener);
}
}
private void notifyDevices() {
synchronized (deviceListeners) {
for (Runnable listener : deviceListeners) listener.run();
}
}
private List<AudioDevice> devices(int flags, boolean input) {
Map<String, AudioDevice> out = new LinkedHashMap<>();
out.put(AudioDevice.DEFAULT.id(), AudioDevice.DEFAULT);
for (AudioDeviceInfo info : audioManager.getDevices(flags)) {
String label = label(info, input);
if (label != null) out.putIfAbsent(key(info), new AudioDevice(key(info), label));
}
return new ArrayList<>(out.values());
}
/** What the settings store for a device. */
private static String key(AudioDeviceInfo info) {
return info.getType() + ":" + info.getAddress();
}
/** The device's current id, or 0 to let the system choose when it is not there. */
private int resolve(String device, int flags) {
if (device == null || device.isEmpty()) return 0;
for (AudioDeviceInfo info : audioManager.getDevices(flags)) {
if (key(info).equals(device)) return info.getId();
}
return 0;
}
private static int flags(OboeLine line) {
return line.isInput() ? AudioManager.GET_DEVICES_INPUTS : AudioManager.GET_DEVICES_OUTPUTS;
}
/** A picked device that dropped out took its lines to the default; bring them back. */
private void returnToDevices(AudioDeviceInfo[] added) {
for (AudioDeviceInfo info : added) {
for (OboeLine line : open) {
if (key(info).equals(line.device) && info.isSource() == line.isInput()) {
reopen(line, info.getId());
}
}
}
}
// ---- lines ----
AudioRoute route() {
return route;
}
/** Takes on a new route, moving the lines it concerns over at once. */
void setRoute(AudioRoute newRoute) {
AudioRoute old = route;
route = newRoute;
synchronized (this) {
updateCall();
}
for (OboeLine line : open) {
boolean affected = line.isInput()
? old.inputPreset() != newRoute.inputPreset()
: old.usage() != newRoute.usage();
if (affected) reopen(line, resolve(line.device, flags(line)));
}
}
private void reopen(OboeLine line, int deviceId) {
reopener.execute(() -> {
try {
line.reopen(deviceId, route);
} catch (IllegalStateException ignored) {
// The stream could not come back on those terms; the line reads silence until closed.
}
});
}
@Override
public AudioCapture openCapture(String deviceId, int preferredChannels) {
opening(true, deviceId);
try {
return register(OboeLine.capture(deviceId, resolve(deviceId, AudioManager.GET_DEVICES_INPUTS),
preferredChannels, route, this::closed));
} finally {
opened();
}
}
@Override
public AudioPlayback openPlayback(String deviceId, int preferredChannels) {
opening(false, deviceId);
try {
return register(OboeLine.playback(deviceId, resolve(deviceId, AudioManager.GET_DEVICES_OUTPUTS),
preferredChannels, route, this::closed));
} finally {
opened();
}
}
/** A stream opens on the call as it will be, so the call is set up for it first. */
private synchronized void opening(boolean input, String device) {
openingInput = input;
openingDevice = device == null ? "" : device;
updateCall();
}
private synchronized void opened() {
openingDevice = null;
updateCall();
}
private synchronized OboeLine register(OboeLine line) {
open.add(line);
return line;
}
private synchronized void closed() {
open.removeIf(line -> !line.isOpen());
updateCall();
}
/**
* Puts the phone into communication mode while a line needs it, pointing the call at
* the picked device, and leaves it again once none does.
*/
private void updateCall() {
AudioDeviceInfo headsetMic = null;
String output = null;
List<String> inputs = new ArrayList<>();
for (OboeLine line : open) {
if (line.isInput()) inputs.add(line.device);
else if (output == null) output = line.device;
}
if (openingDevice != null) {
if (openingInput) inputs.add(openingDevice);
else if (output == null) output = openingDevice;
}
for (String input : inputs) {
AudioDeviceInfo mic = find(input, AudioManager.GET_DEVICES_INPUTS);
if (mic != null && isHeadsetMic(mic)) headsetMic = mic;
}
// Held while anything is open, the microphone included: a notification sound coming
// and going must not take the phone in and out of a call.
boolean call = route.usage() == AudioRoute.Usage.VOICE_COMMUNICATION && (!open.isEmpty() || openingDevice != null);
boolean needed = headsetMic != null || call;
if (needed && !inCall) {
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
inCall = true;
} else if (!needed && inCall) {
audioManager.clearCommunicationDevice();
audioManager.setMode(AudioManager.MODE_NORMAL);
inCall = false;
}
if (!needed) return;
// A headset microphone only works over the headset's call link, so it decides.
AudioDeviceInfo target = headsetMic != null
? communicationDevice(headsetMic.getAddress(), headsetMic.getType())
: communicationDevice(find(output, AudioManager.GET_DEVICES_OUTPUTS));
if (target != null) audioManager.setCommunicationDevice(target);
else audioManager.clearCommunicationDevice();
}
private AudioDeviceInfo find(String device, int flags) {
if (device == null || device.isEmpty()) return null;
for (AudioDeviceInfo info : audioManager.getDevices(flags)) {
if (key(info).equals(device)) return info;
}
return null;
}
private static boolean isHeadsetMic(AudioDeviceInfo info) {
return info.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || info.getType() == AudioDeviceInfo.TYPE_BLE_HEADSET;
}
/** The communication device that is {@code output}, if calls can go there. */
private AudioDeviceInfo communicationDevice(AudioDeviceInfo output) {
if (output == null) return null;
// Calls cannot go over A2DP; the same headset's call profile can.
int type = output.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ? AudioDeviceInfo.TYPE_BLUETOOTH_SCO : output.getType();
return communicationDevice(output.getAddress(), type);
}
private AudioDeviceInfo communicationDevice(String address, int inputType) {
for (AudioDeviceInfo info : audioManager.getAvailableCommunicationDevices()) {
if (info.getType() == inputType && info.getAddress().equals(address)) return info;
}
// Wired and USB headsets, and the built-in microphone, have no address in common with an output.
for (AudioDeviceInfo info : audioManager.getAvailableCommunicationDevices()) {
if (info.getType() == inputType) return info;
}
return null;
}
boolean unprocessedSupported() {
return "true".equals(audioManager.getProperty(AudioManager.PROPERTY_SUPPORT_AUDIO_SOURCE_UNPROCESSED));
}
/** How a device is offered in the settings, or null for one no user would pick. */
private static String label(AudioDeviceInfo info, boolean input) {
String product = info.getProductName() == null ? "" : info.getProductName().toString();
String address = info.getAddress();
switch (info.getType()) {
case AudioDeviceInfo.TYPE_BUILTIN_MIC:
return "Phone microphone" + (address.isEmpty() ? "" : " (" + address + ")");
case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER:
return "Phone speaker";
case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE:
return "Earpiece";
case AudioDeviceInfo.TYPE_WIRED_HEADSET:
return input ? "Wired headset microphone" : "Wired headset";
case AudioDeviceInfo.TYPE_WIRED_HEADPHONES:
return "Wired headphones";
case AudioDeviceInfo.TYPE_USB_HEADSET:
case AudioDeviceInfo.TYPE_USB_DEVICE:
case AudioDeviceInfo.TYPE_USB_ACCESSORY:
return product + " (USB)";
case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP:
return product + " (Bluetooth, media quality)";
case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
return product + " (Bluetooth, call quality)";
case AudioDeviceInfo.TYPE_BLE_HEADSET:
case AudioDeviceInfo.TYPE_BLE_SPEAKER:
return product + " (Bluetooth LE)";
case AudioDeviceInfo.TYPE_HEARING_AID:
return product + " (hearing aid)";
case AudioDeviceInfo.TYPE_HDMI:
case AudioDeviceInfo.TYPE_HDMI_ARC:
case AudioDeviceInfo.TYPE_HDMI_EARC:
return "HDMI";
case AudioDeviceInfo.TYPE_TELEPHONY:
case AudioDeviceInfo.TYPE_REMOTE_SUBMIX:
case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE:
case TYPE_ECHO_REFERENCE:
case AudioDeviceInfo.TYPE_FM_TUNER:
case AudioDeviceInfo.TYPE_TV_TUNER:
case AudioDeviceInfo.TYPE_BUS:
case AudioDeviceInfo.TYPE_BLE_BROADCAST:
return null;
default:
return product.isEmpty() ? "Device " + info.getId() : product;
}
}
}

View File

@@ -0,0 +1,49 @@
package com.ts3client.android.audio;
/**
* How the app's audio meets Android's: which processing the platform applies to the
* microphone, and whether playback is media or a call.
*/
public record AudioRoute(InputPreset inputPreset, Usage usage) {
/** The client runs TS3's own processing, so by default the platform adds as little as it can. */
public static final AudioRoute DEFAULT = new AudioRoute(InputPreset.VOICE_RECOGNITION, Usage.MEDIA);
/** Oboe's input presets, which are Android's audio sources. */
public enum InputPreset {
/** Tuned for speech with light or no processing; what the client expects. */
VOICE_RECOGNITION(6),
/** The platform's call processing: echo cancellation, noise suppression, gain control. */
VOICE_COMMUNICATION(7),
/** No processing at all, where the device supports it. */
UNPROCESSED(9),
/** Low latency and no processing, meant for live performance. */
VOICE_PERFORMANCE(10),
/** The plain microphone source, with whatever the vendor applies to it. */
GENERIC(1),
CAMCORDER(5);
final int oboeValue;
InputPreset(int oboeValue) {
this.oboeValue = oboeValue;
}
}
/** Oboe's playback usages. */
public enum Usage {
/** Plays like music: stays on A2DP headphones and leaves the phone out of call mode. */
MEDIA(1),
/**
* Plays as a call: Android goes into communication mode, which is what its echo
* canceller works against, and Bluetooth headsets switch to their headset profile.
*/
VOICE_COMMUNICATION(2);
final int oboeValue;
Usage(int oboeValue) {
this.oboeValue = oboeValue;
}
}
}

View File

@@ -0,0 +1,114 @@
package com.ts3client.android.audio;
import com.ts3client.audio.opus.CtlOpusEncoder;
import com.ts3client.audio.opus.LibOpus;
import com.ts3client.audio.opus.OpusCodec;
import com.ts3client.audio.opus.OpusDecoder;
import com.ts3client.audio.opus.OpusEncoder;
/** libopus, built into the app with the NDK. */
public final class NativeOpus implements OpusCodec {
static {
System.loadLibrary("ts3audio");
}
@Override
public OpusEncoder createEncoder(int sampleRate, int frameSize, int channels, Application application) {
return new Encoder(encoderCreate(sampleRate, channels, LibOpus.application(application)), frameSize);
}
@Override
public OpusDecoder createDecoder(int sampleRate, int maxFrameSize, int channels) {
return new Decoder(decoderCreate(sampleRate, channels), maxFrameSize);
}
@Override
public String version() {
return version0();
}
/** Methods may come from different threads, one at a time, so they only share the handle. */
private static final class Encoder extends CtlOpusEncoder {
private final int frameSize;
private long handle;
Encoder(long handle, int frameSize) {
this.handle = handle;
this.frameSize = frameSize;
}
@Override
protected synchronized void ctl(int request, int value) {
if (handle != 0) encoderCtl(handle, request, value);
}
@Override
public synchronized byte[] encode(float[] pcm) {
if (handle == 0) throw new IllegalStateException("encoder closed");
return NativeOpus.encode(handle, pcm, frameSize);
}
@Override
public synchronized void close() {
if (handle == 0) return;
encoderDestroy(handle);
handle = 0;
}
}
private static final class Decoder implements OpusDecoder {
private final int maxFrameSize;
private long handle;
Decoder(long handle, int maxFrameSize) {
this.handle = handle;
this.maxFrameSize = maxFrameSize;
}
@Override
public int decode(byte[] packet, float[] out) {
return NativeOpus.decode(live(), packet, out, maxFrameSize);
}
@Override
public int conceal(float[] out, int frameSize) {
return NativeOpus.decode(live(), null, out, Math.min(frameSize, maxFrameSize));
}
@Override
public void reset() {
if (handle != 0) decoderReset(handle);
}
@Override
public void close() {
if (handle == 0) return;
decoderDestroy(handle);
handle = 0;
}
private long live() {
if (handle == 0) throw new IllegalStateException("decoder closed");
return handle;
}
}
private static native long encoderCreate(int sampleRate, int channels, int application);
private static native void encoderCtl(long handle, int request, int value);
private static native byte[] encode(long handle, float[] pcm, int frameSize);
private static native void encoderDestroy(long handle);
private static native long decoderCreate(int sampleRate, int channels);
private static native int decode(long handle, byte[] packet, float[] out, int frameSize);
private static native void decoderReset(long handle);
private static native void decoderDestroy(long handle);
private static native String version0();
}

View File

@@ -0,0 +1,115 @@
package com.ts3client.android.audio;
import com.ts3client.audio.AudioCapture;
import com.ts3client.audio.AudioPlayback;
/**
* An Oboe stream, as either an {@link AudioCapture} or an {@link AudioPlayback}. The native
* side keeps a ring between the real-time callback and these blocking calls.
*/
final class OboeLine implements AudioCapture, AudioPlayback {
static {
System.loadLibrary("ts3audio");
}
/** Held across native reads and writes, so {@link #close} never frees a line in use. */
private final Object io = new Object();
private final int channels;
private final boolean input;
/** The device as the settings name it, to find it again when the line is reopened. */
final String device;
private final Runnable onClose;
private volatile long handle;
private OboeLine(boolean input, String device, int deviceId, int channels, AudioRoute route, Runnable onClose) {
this.input = input;
this.device = device;
this.onClose = onClose;
this.handle = nativeOpen(input, deviceId, channels, route.inputPreset().oboeValue, route.usage().oboeValue);
this.channels = nativeChannels(handle);
}
static OboeLine capture(String device, int deviceId, int channels, AudioRoute route, Runnable onClose) {
return new OboeLine(true, device, deviceId, channels, route, onClose);
}
static OboeLine playback(String device, int deviceId, int channels, AudioRoute route, Runnable onClose) {
return new OboeLine(false, device, deviceId, channels, route, onClose);
}
boolean isInput() {
return input;
}
boolean isOpen() {
return handle != 0;
}
/** Moves the open stream onto {@code deviceId} with {@code route}; readers and writers carry on. */
void reopen(int deviceId, AudioRoute route) {
long h = handle;
if (h != 0) nativeReopen(h, deviceId, route.inputPreset().oboeValue, route.usage().oboeValue);
}
@Override
public int channels() {
return channels;
}
@Override
public void start() {
long h = handle;
if (h != 0) nativeStart(h);
}
@Override
public int read(byte[] buffer, int offset, int length) {
synchronized (io) {
long h = handle;
return h == 0 ? 0 : nativeRead(h, buffer, offset, length);
}
}
@Override
public void write(byte[] buffer, int offset, int length) {
synchronized (io) {
long h = handle;
if (h != 0) nativeWrite(h, buffer, offset, length);
}
}
/** Oboe has no drain; what is queued is at most a few frames and simply plays out. */
@Override
public void drain() {
}
@Override
public void close() {
long h = handle;
if (h == 0) return;
nativeClose(h); // wakes a blocked read or write
synchronized (io) {
if (handle == 0) return;
handle = 0;
nativeDelete(h);
}
onClose.run();
}
private static native long nativeOpen(boolean input, int deviceId, int channels, int preset, int usage);
private static native void nativeReopen(long handle, int deviceId, int preset, int usage);
private static native int nativeChannels(long handle);
private static native void nativeStart(long handle);
private static native int nativeRead(long handle, byte[] buffer, int offset, int length);
private static native void nativeWrite(long handle, byte[] buffer, int offset, int length);
private static native void nativeClose(long handle);
private static native void nativeDelete(long handle);
}

5
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false
}

View File

@@ -0,0 +1,31 @@
plugins {
`java-library`
}
val ts3ClientDir: String = providers.gradleProperty("ts3.client.dir")
.orElse(providers.environmentVariable("TS3_CLIENT_DIR"))
.getOrElse(rootDir.resolve("../TeamSpeak3-Client-linux_amd64").path)
/** TeamSpeak's own icon packs and country flags, proprietary and so taken from an install, as the desktop's build does. */
val teamspeakIconPacks = tasks.register<Sync>("teamspeakIconPacks") {
into(layout.buildDirectory.dir("generated/iconPacks"))
from(File(ts3ClientDir, "gfx")) {
include("default.zip", "default_colored_2014.zip", "default_mono_2014.zip", "countries.zip")
into("gfx/teamspeak")
}
}
java {
sourceSets["main"].java.setSrcDirs(listOf(rootDir.resolve("../ts3-client/core/src/main/java")))
sourceSets["main"].resources.setSrcDirs(listOf(rootDir.resolve("../ts3-client/core/src/main/resources")))
sourceSets["main"].resources.srcDir(teamspeakIconPacks)
}
tasks.withType<JavaCompile>().configureEach {
options.release = 17
options.encoding = "UTF-8"
}
dependencies {
api(project(":ts3j"))
}

View File

@@ -0,0 +1,20 @@
plugins {
`java-library`
}
// The desktop client's platform code, run on the build machine only: its libopus
// encodes the sound packs the app ships (see :app's bundleSoundPacks).
java {
sourceSets["main"].java.setSrcDirs(listOf(rootDir.resolve("../ts3-client/desktop/src/main/java")))
sourceSets["main"].resources.setSrcDirs(listOf(rootDir.resolve("../ts3-client/desktop/src/main/resources")))
}
tasks.withType<JavaCompile>().configureEach {
// The FFM API it reaches libopus through; the build has to run on JDK 25 or newer.
options.release = 25
options.encoding = "UTF-8"
}
dependencies {
implementation(project(":core"))
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.configuration-cache=true
android.useAndroidX=true

View File

@@ -0,0 +1,28 @@
[versions]
agp = "9.4.1"
kotlin = "2.4.20"
composeBom = "2026.09.00"
activityCompose = "1.13.0"
lifecycle = "2.11.0"
bouncycastle = "1.86"
ini4j = "0.5.1"
oboe = "1.11.0"
androidsvg = "1.4"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material-icons = { module = "androidx.compose.material:material-icons-extended" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" }
androidsvg = { module = "com.caverock:androidsvg-aar", version.ref = "androidsvg" }
bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" }
ini4j = { module = "org.ini4j:ini4j", version.ref = "ini4j" }
oboe = { module = "com.google.oboe:oboe", version.ref = "oboe" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

Binary file not shown.

View File

@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
android/gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

112
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,112 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

View File

@@ -0,0 +1,20 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ts3-android"
// core, ts3j and desktop compile the very sources the desktop's Maven build does.
include(":ts3j", ":core", ":desktop", ":audio", ":app")

View File

@@ -0,0 +1,18 @@
plugins {
`java-library`
}
java {
sourceSets["main"].java.setSrcDirs(listOf(rootDir.resolve("../ts3j/src/main/java")))
}
tasks.withType<JavaCompile>().configureEach {
options.release = 17
options.encoding = "UTF-8"
}
dependencies {
// Android ships a BouncyCastle of its own, but under a different package, so they never meet.
api(libs.bouncycastle)
api(libs.ini4j)
}

79
ts3-client/AUDIT.md Normal file
View File

@@ -0,0 +1,79 @@
# Codebase audit — ts3-client
First audit: 2026-09-11. Revisited and worked through: 2026-09-25. Every finding below was
either fixed (commit given) or closed with a reason. Logging (§3) was left out on purpose.
State after the fixes: `mvn -o test` passes (177 tests: 25 in ts3j, 135 core, 11 desktop,
6 swing), on Java 25 and 26. The Android release build (R8 over core and ts3j) passes. The
Swing client was run against the test server on Java 25: it connected, loaded the tree and
chat logs, and wrote its profile owner-only.
---
## 1. Fixed
### Tests and build
| Finding | Commit |
|---|---|
| The importer test failed on every run after the first. The cause was not the real profile: the POM already points `user.home` at `target/test-home`, but that folder outlives a run, so the first run's identity was still there | `ba5de5a` |
| The ts3j DNS test resolved `voice.teamspeak.com` for real and failed when DNS did not answer | ts3j `Test SRV lookup…`, `acb3efa` |
| README architecture and features out of date | `51f19c2` |
| Java 26 required for no reason; it now needs 25 (LTS), and builds, tests and runs on it | `85c3ae6` |
| Missing tests: channel tree, event handler, settings round-trip and migration | `71ac2c8` |
### Connection lifecycle and threading
| Finding | Commit |
|---|---|
| An action racing a disconnect showed `NullPointerException` as the error; 20 raw threads. Actions now capture the socket and run on a pool that lives as long as the connection | `f350b19` |
| `connect()` on a live instance leaked socket and audio; fields shared across threads were not volatile | `d28bb57` |
| Permission errors were recognised by substring; now by the server's error id | `d074ffa` |
| `HotkeyEngine` locked on another class's list; `Hotkeys.all()` now returns a snapshot | `5b1809c` |
| Two quick tab switches could interleave the microphone hand-over | `665316d` |
| `MainFrame` was also the app controller: microphone ownership, push-to-talk, away and nickname fan-out moved to `core/session/Sessions` | `bf07f0b` |
### Model and events
| Finding | Commit |
|---|---|
| `buildTree()` refilled lists shared with the UI; node fields not volatile. Lists are now published whole and unmodifiable. Whether a channel is full is counted from the model | `67f8f46` |
| `selfPermissionValue` scanned every permission; `primaryServerGroupName` unused | `8c735a0` |
| A cleared avatar, description or away message was ignored | `7cb4f47` |
### Voice
| Finding | Commit |
|---|---|
| Fixed 60 ms jitter delay instead of TS3's adaptive buffer. The stashed libspeex port is now in `VoiceStream`, driven as TS3 drives it. A simulated 0–60 ms jitter stops concealing after about 3 s | `0083dc8` |
| A playback line kept open for every client that ever spoke | `6234ec6` |
| A dead playback line silently muted its speaker for the whole session | `12ca585` |
| `CaptureVoiceInput.stop()` tore down while the capture loop was still running | `8e9c461` |
### Chat logs
| Finding | Commit |
|---|---|
| Multi-line messages lost their continuation lines in history | `f9b2f4d` |
| History read whole, ever-growing logs; now the last 4 MiB | `a5c4ba1` |
| `appendPrivateMessage` duplicated `appendMessage` | `1a3a3e3` |
| Logs could not be turned off or moved (new Options → Chat page) | `d7261c9` |
| Chat logs only worked with admin rights: the server id came from `serverinfo`, which guests may not use. It turned out to be base64(SHA-1(the server's handshake key)), as the official client derives it. Found while checking the "`serverUniqueId` fallbacks can go" finding | ts3j `Derive the server's unique id…`, `eeccb49` |
### Persistence, file transfers, UI
| Finding | Commit |
|---|---|
| Profile files (private keys, server passwords) readable by everyone, and truncated by a crash mid-write. Now written privately and atomically, and the profile folder is owner-only | `3cdade9` |
| Three copies of the enum parser in `Settings` | `48a0032` |
| A stalled file server hung a download forever | `ae8d1f7` |
| Collapsed channels reopened on every tree rebuild | `f30cecf` |
## 2. Closed without a change, or still open
- **Chat-log folders "may contain `/`"**: they cannot. The value encoded is itself Base64 text,
and no 6-bit group of such bytes reaches 62 or 63, the codes for `+` and `/`.
- **`rootMessage()` hides the command**: every caller names the action in front of it.
- **Not tested:** `TeamspeakConnection`'s lifecycle against a fake socket (the smoke test
above covers connect only); the idle release and dead-line replacement in
`PerSpeakerPlayout`; the jitter buffer with real voice from another client.
- **`git stash@{0}`** ("jitter buffer test") is now fully ported, except FEC decoding (TS3
does not use FEC either). It can be dropped.
## 3. Left out on purpose
- **Logging.** There are still 60 swallowed exceptions and no log output.

View File

@@ -10,13 +10,14 @@ TeaVM/CheerpJ) without touching the library:
| Module | Artifact | Responsibility | | Module | Artifact | Responsibility |
|--------|----------|----------------| |--------|----------|----------------|
| `core` | `ts3-client-core` | Frontend-agnostic library: protocol integration, server model, connection orchestration, audio **abstractions**. No UI, no platform audio, no native code. | | `core` | `ts3-client-core` | Frontend-agnostic library: protocol integration, server model, connection orchestration, the voice pipelines and their DSP. No UI, no platform audio API, no native code. |
| `desktop` | `ts3-client-desktop` | Desktop audio backend: Java Sound capture/playback + native Opus via the FFM API (project Panama), implementing the core audio interfaces. | | `desktop` | `ts3-client-desktop` | Desktop audio backend: PipeWire (or Java Sound) capture/playback + native Opus via the FFM API (project Panama), plus the global hotkey hooks. |
| `swing` | `ts3-client-swing` | Swing desktop UI + entry point. Depends on `core` and `desktop`. | | `swing` | `ts3-client-swing` | Swing desktop UI + entry point. Depends on `core` and `desktop`. |
The core exposes `AudioBackend` / `VoiceInput` / `VoiceOutput`; the frontend injects The core exposes `AudioBackend` / `VoiceInput` / `VoiceOutput`; the frontend injects
a concrete backend (`JavaSoundAudioBackend`) into `TeamspeakConnection`. A different a concrete backend (`DesktopAudioBackend`) into `TeamspeakConnection`. A different
frontend supplies its own UI and audio backend while reusing `core` unchanged. frontend supplies its own UI and audio backend while reusing `core` unchanged, as the
Android app in [`../android`](../android) does.
## Features ## Features
@@ -28,26 +29,26 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
elsewhere install libopus from your package manager. Encoding at 48 kHz, 20 ms frames. elsewhere install libopus from your package manager. Encoding at 48 kHz, 20 ms frames.
- **Voice Activation Detection (VAD)** with the same three modes as the TS3 client: - **Voice Activation Detection (VAD)** with the same three modes as the TS3 client:
- **Volume Gate** — RMS/dBFS threshold with a live input meter and hangover. - **Volume Gate** — RMS/dBFS threshold with a live input meter and hangover.
- **Automatic** — a dependency-free speech detector (short-term energy + spectral - **Automatic** — a Java port of WebRTC's RNN speech detector (`rnn_vad`), the one the
flatness + dominant frequency against an adaptive noise floor). TS3 client uses, with the same weights.
- **Hybrid** — transmit only when loud enough **and** detected as speech. - **Hybrid** — transmit only when loud enough **and** detected as speech.
- Optional **VAD over Push-To-Talk** (keep detecting voice while PTT is available). - Optional **VAD over Push-To-Talk** (keep detecting voice while PTT is available).
- **Capture pre-processing** — a mini audio-processing chain in the same order as the - **Capture pre-processing** — a Java port of the WebRTC audio-processing stages the TS3
TS3 client's WebRTC APM, applied before activation and encoding (and feeding the VAD): client enables, in its order: high-pass filter → **noise suppression** (WebRTC NS, TS3's
input gain → **high-pass filter** (80 Hz, always-on rumble/DC removal) → **noise four levels) → **typing attenuation** (the transient suppressor, told about key presses)
suppression** → **typing attenuation** → **AGC**: → **automatic gain control** (AGC2, WebRTC's successor to the AGC1 TS3 uses). Checked
- **Remove background noise** — spectral denoiser (decision-directed Wiener with against WebRTC built from source.
minimum-statistics noise tracking), with an adjustable removal level. - **Boost quiet speech** (0–20 dB) raises AGC2's cap on the amplified noise floor, which
- **Typing attenuation** — detects impulsive keystroke transients (short, broadband, otherwise holds back a distant voice that is barely above the background noise.
high-frequency bursts) and ducks them while leaving sustained speech intact.
- **Automatic gain control (AGC)** — normalises mic loudness to a target level
(fast attack / slow release, noise-gated so silence is never amplified).
- Echo cancellation (WebRTC AEC3) is intentionally omitted — it needs the loudspeaker - Echo cancellation (WebRTC AEC3) is intentionally omitted — it needs the loudspeaker
reference signal and matters mainly for open speakers, not the typical headset. reference signal and matters mainly for open speakers, not the typical headset.
- **Push-to-Talk** — bind any key; transmits only while held (while the app is focused). - **Push-to-Talk** — bind any key or mouse button as a global hotkey; transmits only while held.
- **Continuous** transmission mode. - **Continuous** transmission mode.
- **Playback mixing** — each speaker gets its own Opus decoder and audio line, so - **Adaptive jitter buffer** — a port of the libspeex jitter buffer the TS3 client uses,
multiple simultaneous talkers are mixed and one slow decode never blocks others. set up and driven as it does: the delay follows the measured network jitter, and what it
has learned carries over between talk bursts.
- **Playback mixing** — each speaker gets their own Opus decoder and audio line (a stream
of their own in the PipeWire mixer), so one slow decode never blocks the others.
- **Whisper receive** — targeted voice is decoded and played like normal voice. - **Whisper receive** — targeted voice is decoded and played like normal voice.
- Per-client **mute**, master **deafen**, adjustable mic gain / playback volume. - Per-client **mute**, master **deafen**, adjustable mic gain / playback volume.
- **On-the-fly Opus tuning** — bitrate, complexity, VBR, FEC and voice/music codec - **On-the-fly Opus tuning** — bitrate, complexity, VBR, FEC and voice/music codec
@@ -71,15 +72,76 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
**private chat**, or **locally mute** them. **private chat**, or **locally mute** them.
- **Chat** to the current channel or the whole server; receive channel/server/private - **Chat** to the current channel or the whole server; receive channel/server/private
messages and **pokes**. messages and **pokes**.
- **Avatars** — a selected client's avatar is shown in the info panel, fetched from the
server's file repository (`/avatar_<id>` in channel 0, verified against the client's
`client_flag_avatar` MD5) and cached on disk; **animated GIFs play**. Self → Set
avatar… uploads your own picture (PNG/JPEG/GIF/BMP, within the server's
`i_client_max_avatar_filesize`) or removes it.
- **Server bookmarks** — quick-connect menu with add/edit/remove management, each - **Server bookmarks** — quick-connect menu with add/edit/remove management, each
optionally pinned to a specific identity. optionally pinned to a specific identity.
- **Identity management** (Tools → Identities) — keep several identities, mark one - **Identity management** (Tools → Identities) — keep several identities, mark one
as the default, pick one per server or per bookmark, rename, raise an identity's as the default, pick one per server or per bookmark, rename, raise an identity's
security level, and import/export TeamSpeak-compatible `.ini` identity files. security level, and import/export TeamSpeak-compatible `.ini` identity files.
- **Import from TeamSpeak 3 / myTeamSpeak** — the official client keeps its bookmarks
and identities (the data a myTeamSpeak account synchronises) as protobuf
`Item_Data` blobs in the `ProtobufItems` table of its `settings.db`. On first run,
when this client has no identity of its own yet, it adopts all of them — identities,
the default identity and its nickname, and the bookmarks with the identities they
connect with; "Import from TeamSpeak 3…" in Manage Bookmarks and in Identities
re-imports at any time (additive: known identities and bookmarks are left alone).
The schema was recovered from the descriptors embedded in the client binary.
- **Contacts** (Tools → Contacts) — TeamSpeak's friend/blocked list: mark a client as
**Friend**, **Blocked** or **Neutral** from its context menu (or drag it into the
window), give it a custom and a phonetic nickname, choose how the name is shown
(custom name and nickname / only custom / only nickname), and per contact **mute
automatically**, **ignore** server/channel chat, private chat, pokes and away
messages, and **allow or deny whispers**. Friends are coloured green and blocked
clients red in the tree (optional), per-type defaults for new contacts are
configurable ("Set Defaults"), and the global whisper policy lives in Options →
Contacts. The list is stored in `~/.ts3jclient/contacts.txt` in the *exact* record
format the official client keeps in the `Contacts` table of its `settings.db`; on
first run it is seeded from an installed TeamSpeak 3 client's database (read with a
small built-in SQLite reader — no driver needed), and can be re-imported any time.
- **Per-client volume** ("Change Volume…") — a dB gain on top of the master volume,
remembered in the client's contact entry.
- **Ban List** (Tools → Ban List) — the server's bans in a searchable table (Add /
Remove / Edit, own-ban filter and highlighting, choosable columns), with the
official Add dialog: IP and nickname patterns read as IPv4/IPv6 wildcards, fixed
strings or regular expressions, unique id, myTeamSpeak id, reason presets and a
duration capped by `i_client_ban_max_bantime`.
- **Self status** — Away (with message) and Channel Commander toggles. - **Self status** — Away (with message) and Channel Commander toggles.
- **Status bar** shows the server name, user count and live ping. - **Status bar** shows the server name, user count and live ping.
- Change your nickname, mute/deafen from the toolbar. - Change your nickname, mute/deafen from the toolbar.
### Hotkeys
- **Global hotkeys**, as in TeamSpeak 3: single keys or combinations (any key can act
as the modifier) and mouse buttons including *Mouse 4* / *Mouse 5*, working
system-wide rather than only while the window has focus. Configure them in
**Options → Hotkeys**.
- Per binding you choose whether it **triggers on key down or on key up**, and whether
it applies **on the active server** only or to every connected one (checkbox off).
Push-to-talk style actions ignore the trigger setting and simply last while held.
- The action list is TeamSpeak's own, reverse-engineered from its hotkey dialog:
three categories (*Server*, *Self*, *Misc*) and a **"Show advanced actions"**
checkbox that reveals the rest, exactly as the original hides all but the everyday
actions. Actions this client does not implement are listed but greyed out.
- Push-to-talk is one of those hotkeys; the button in **Options → Voice Activation**
edits that binding.
- On Linux, capture uses **XInput2 raw events**, as TeamSpeak's own client does: no
privileges needed and the keystroke is not swallowed. X11's RECORD extension is kept
as a fallback for servers without XInput2.
- On **Windows**, capture uses **raw input** (`RIDEV_INPUTSINK`) through a message-only
window: it observes rather than intercepts, so unlike a low-level keyboard hook it
cannot swallow a keystroke or stall the system input queue, and it reports the side
buttons and both edges of every key. Bindings are stored as scan codes, so they follow
the physical key rather than the layout.
- On Wayland the hooks run inside Xwayland, so they see every X11 application, and keys
aimed at native Wayland windows only where the compositor forwards them — under KWin
that is *System Settings → Window Management → Legacy X11 App Support*, which is why
TeamSpeak's hotkeys work there. Where it forwards nothing, the client reads
`/dev/input/event*` instead, for which your user has to be in the `input` group.
The Hotkeys tab says which backend is in use, or why none is.
### Notification sounds ### Notification sounds
- **Sound packs** in TeamSpeak's own format: a folder of waves plus a `settings.ini` - **Sound packs** in TeamSpeak's own format: a folder of waves plus a `settings.ini`
mapping actions (`CONNECTION_CONNECTED`, `CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS`, …) mapping actions (`CONNECTION_CONNECTED`, `CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS`, …)
@@ -100,6 +162,11 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
TeamSpeak 3 client (`$TS3_CLIENT_DIR` or the usual install paths) and a folder of TeamSpeak 3 client (`$TS3_CLIENT_DIR` or the usual install paths) and a folder of
your choosing, so the official packs (`default_colored_2014.zip`, your choosing, so the official packs (`default_colored_2014.zip`,
`default_mono_2014.zip`, the legacy `default.zip`) work unchanged. `default_mono_2014.zip`, the legacy `default.zip`) work unchanged.
- **Material**, a pack in the same format built from Google's Material icons, ships
with the client (`core/src/main/resources/gfx/material`, generated by
`tools/material-icon-pack.py`). Its monochrome icons paint with `currentColor`, which
follows the theme's text colour; it covers every icon TeamSpeak's own packs do. It is
the Android app's default, and the Android app draws TeamSpeak packs too — import one (e.g. `default_colored_2014.zip`) in its settings.
- The pack draws the tree (server, channel, client state), the toolbar, the menus, - The pack draws the tree (server, channel, client state), the toolbar, the menus,
the context menus, the file browser and TeamSpeak's default group icons. Vector the context menus, the file browser and TeamSpeak's default group icons. Vector
art is rasterised at the size it is drawn at; a pack's `FALLBACK` option decides art is rasterised at the size it is drawn at; a pack's `FALLBACK` option decides
@@ -108,19 +175,31 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
- **Pick one in Options → Design**, which also shows a viewer of everything the pack - **Pick one in Options → Design**, which also shows a viewer of everything the pack
contains; switching redraws the window right away. contains; switching redraws the window right away.
### Display scaling (Linux)
- Java2D on X11 (and so under XWayland) only scales by whole numbers, so 125 % or
150 % comes out at 100 %. On Linux the client pins Java2D to 1 and hands the full
factor to FlatLaf instead, scaling its own sizes and icons to match.
- The factor follows the desktop: XSETTINGS `Xft/DPI` (GNOME, Plasma), else KWin's
XWayland scale, else `GDK_SCALE`. For crisp text on Wayland, let X11 apps scale
themselves (Plasma: *Display Configuration → Legacy Applications → Apply scaling
themselves*; GNOME: the `xwayland-native-scaling` mutter feature), otherwise the
compositor upscales the window.
- **Options → Design → Interface scale** overrides it (after a restart);
`-Dflatlaf.uiScale=` or `-Dsun.java2d.uiScale=` on the command line bypasses all this.
## Requirements ## Requirements
- Java 26+ (developed/tested on Temurin 26) - Java 25+ (the LTS; developed on Temurin 26)
- The native Opus library on the system: - The native Opus library on the system:
- Debian/Ubuntu: `sudo apt install libopus0` - Debian/Ubuntu: `sudo apt install libopus0`
- Arch: `sudo pacman -S opus` - Arch: `sudo pacman -S opus`
- macOS: `brew install opus` - macOS: `brew install opus`
## Building ## Building
The client depends on `ts3j`, so install that into your local Maven repo first: The build compiles `ts3j` from `../ts3j` alongside the client, so make sure the submodule is
checked out (`git submodule update --init`):
```bash ```bash
cd ../ts3j && mvn -DskipTests install mvn -DskipTests package
cd ../ts3-client && mvn -DskipTests package
``` ```
This produces a runnable fat-jar at `swing/target/ts3-client.jar`. This produces a runnable fat-jar at `swing/target/ts3-client.jar`.
@@ -139,39 +218,71 @@ voice activation while watching the live meter.
## Architecture ## Architecture
``` ```
core/ com.ts3client core/ com.ts3client
├── config.Settings persisted prefs (~/.ts3jclient/settings.properties) ├── config the profile (~/.ts3jclient): Settings, Bookmarks, IdentityStore,
├── config.Bookmarks persisted server bookmarks (with per-server identity) │ AwayMessages, …; AppDirs finds it, ProfileFiles writes it
├── config.IdentityStore managed identities (~/.ts3jclient/identities/*.ini) │ privately and all at once
├── audio abstractions + reusable DSP (no platform code) ├── session
│ ├── AudioBackend factory for a platform's VoiceInput/VoiceOutput │ ├── ServerSession one server tab's state and actions, whatever the frontend
│ ├── VoiceInput capture source (extends ts3j Microphone) + gating controls │ └── Sessions what the servers share: the microphone, away state, nickname
│ ├── VoiceOutput voice-packet playback sink ├── net
│ ├── OpusParameters live-tunable encoder settings │ ├── TeamspeakConnection ties socket + audio + model; runs requests on its own pool
│ └── SpeechDetector feature-based speech-probability VAD (Automatic/Hybrid) │ ├── ConnectionEventHandler server events → model changes, sounds and log lines
│ ├── ServerModel channel/client state; ChannelNode/ClientEntry view models
│ ├── ChannelAdmin/BanAdmin/AvatarAdmin channel editing, the ban list, our own avatar
│ ├── IconRepository server icons (avatar/ holds the client avatar cache)
│ ├── filetransfer channel file browsing, up- and downloads
│ └── ConnectionListener frontend callbacks
├── audio the voice pipelines every platform shares
│ ├── AudioBackend/AudioIo what a platform supplies: devices, lines, Opus
│ ├── CaptureVoiceInput capture → processing → VAD/PTT gate → Opus encode
│ ├── StreamingVoiceOutput per-speaker decode, each through a VoiceStream
│ ├── JitterBuffer port of the libspeex jitter buffer TS3 uses
│ ├── PerSpeakerPlayout a line per speaker (desktop); MixedPlayout mixes onto one (Android)
│ ├── processing port of TS3's WebRTC capture chain: HPF, NS, transients, AGC2
│ ├── vad port of WebRTC's rnn_vad speech detector
│ └── opus codec interfaces
├── chatlog TS3-compatible chat logs, shared with the TS3 client by default
├── contacts friends and blocked clients, in TS3's own record format
├── text BBCode, TeamSpeak links, chat search
├── gfx ├── gfx
│ ├── IconPack an icon pack zip/folder + its settings.ini mapping │ ├── IconPack an icon pack zip/folder/classpath folder + its settings.ini mapping
│ └── IconPacks discovery of installed packs │ ├── IconPacks discovery of installed packs, the shipped Material one, lookup order
│ └── IconKeys the icon keys the channel tree asks for
├── hotkey
│ ├── HotkeyAction catalogue of TeamSpeak's hotkey actions (RE'd from its binary)
│ ├── Hotkey/HotkeyCombo one binding: keys, trigger edge, server scope, argument
│ ├── Hotkeys persisted bindings (~/.ts3jclient/hotkeys.properties)
│ ├── GlobalInputHook platform hook interface: system-wide key/button events
│ └── HotkeyEngine held-key tracking, combination matching, recording
├── sound ├── sound
│ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids) │ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids)
│ ├── SoundPack a pack folder + its settings.ini mapping │ ├── SoundPack a pack folder + its settings.ini mapping
│ ├── SoundPacks discovery of installed packs │ ├── SoundPacks discovery of installed packs
│ ├── NotificationSettings per-action enabled / important flags │ ├── NotificationSettings per-action enabled / important flags
│ ├── SoundNotifier decides what is heard (pack + config + mute state) │ ├── SoundNotifier decides what is heard (pack + config + mute state)
│ └── SoundPlayer platform hook for rendering a sound │ ├── SoundPlayer renders a sound
└── net │ └── PackSoundPlayer decodes, resamples and mixes sounds onto one line
├── TeamspeakConnection ties socket + audio backend + model, translates events └── teamspeak the official client's own data files
├── ServerModel thread-safe channel/client state ├── TeamSpeakSettingsDb locates settings.db; reads Contacts and the ProtobufItems sync store
├── ChannelNode/ClientEntry view models ├── SqliteReader minimal read-only SQLite 3 file reader (no driver)
└── ConnectionListener frontend callbacks ├── SyncItem a synced bookmark / identity / folder (Item_Data)
├── SyncItemDecoder protobuf wire decoding of Item_Data, via ProtobufReader
└── TeamSpeakImporter merges synced items into IdentityStore / Bookmarks
desktop/ com.ts3client.audio.desktop desktop/ com.ts3client.audio.desktop + com.ts3client.hotkey.desktop
├── Opus Panama (FFM) binding to native libopus ├── DesktopAudioBackend PipeWire where it runs, Java Sound elsewhere, and native Opus
├── OpusEncoder/OpusDecoder thin codec wrappers ├── Opus/NativeOpusCodec Panama (FFM) binding to native libopus
├── AudioDevices device enumeration + line opening (48 kHz/16-bit) ├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode ├── JavaSoundCapture/JavaSoundPlayback Java Sound lines
├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing ├── pipewire PipeWire streams through FFM
├── WavSoundPlayer sound-pack playback: decode, resample and mix on one line └── hotkey.desktop
└── JavaSoundAudioBackend wires the above into the core AudioBackend ├── XInput2InputHook global key/button capture via XInput2 raw events
├── XRecordInputHook the same via X11's RECORD extension, as a fallback
├── EvdevInputHook /dev/input fallback for Wayland sessions
├── WindowsInputHook the same on Windows: raw input into a message-only window
├── RawInput RAWINPUT layout and decoding, split out to be testable
├── X11KeyNamer/WindowsKeyNamer layout-aware key labels per platform
└── DesktopInputHooks picks the backend that suits the session
swing/ com.ts3client swing/ com.ts3client
├── Main entry point (look & feel, settings, backend injection) ├── Main entry point (look & feel, settings, backend injection)
@@ -181,9 +292,14 @@ swing/ com.ts3client
├── Spacers TS3 spacer-channel name parsing/rendering ├── Spacers TS3 spacer-channel name parsing/rendering
├── InfoPanel channel description / client group + details view ├── InfoPanel channel description / client group + details view
├── ChatPanel chat log + input ├── ChatPanel chat log + input
├── SettingsDialog audio + VAD options with live meter ├── SettingsDialog the Options dialog: TS3-style page sidebar; one panel per page (ApplicationPanel, …, ChatLogsPanel)
├── HotkeysPanel hotkey list (Options → Hotkeys)
├── HotkeyDialog add/edit one hotkey: action, combination, trigger, scope
├── HotkeyService bindings + engine + input hook, for the dialogs
├── HotkeyActions carries a fired hotkey out on the client
├── NotificationsPanel sound pack + per-action sound/important configuration ├── NotificationsPanel sound pack + per-action sound/important configuration
├── IconPackPanel icon pack chooser + icon viewer (Options → Design) ├── IconPackPanel icon pack chooser + icon viewer (Options → Design)
├── DisplayScale interface scale on Linux (fractional, via FlatLaf)
├── ConnectDialog connect form ├── ConnectDialog connect form
├── BookmarksDialog manage saved servers ├── BookmarksDialog manage saved servers
├── IdentitiesDialog manage identities (new/import/export/improve) ├── IdentitiesDialog manage identities (new/import/export/improve)
@@ -195,11 +311,15 @@ swing/ com.ts3client
``` ```
## Known limitations / next steps ## Known limitations / next steps
- The **Automatic/Hybrid** VAD uses a lightweight energy/spectral detector rather
than the WebRTC GMM model the official client ships; it is intentionally
dependency-free and reusable in the core library.
- Whisper is received/played but not yet **sendable** from the UI. - Whisper is received/played but not yet **sendable** from the UI.
- Playback decodes streams as mono; stereo music-bot audio is down-mixed. - Hotkey actions TeamSpeak offers but this client does not perform yet (listed but
- Push-to-talk is captured via Swing key events, so it only works while the app greyed out in the dialog): capture/playback/hotkey profiles and sound packs,
window has focus (no global hotkey). whisper and push-to-whisper, recording, plugins, server groups, talk power, 3D
- No file transfer, avatars, or server/channel administration UI yet. sound, hardware ("local") microphone mute and the channel-traversal variants
beyond "Switch to Channel".
- Global hotkeys cover Linux and Windows; **macOS has no backend**, so hotkeys there are
inert (a `CGEventTap`, which needs Accessibility permission, is the way in). The
Windows backend is written but has not been run on Windows — only its decoding is
covered by tests.
- When no backend can start, push-to-talk does not work at all, not even with the window
focused; a focus-scoped fallback hook would restore the pre-hotkey behaviour.

View File

@@ -14,6 +14,13 @@
<name>TS3J Client Core</name> <name>TS3J Client Core</name>
<description>Frontend-agnostic library: protocol integration, model, audio abstractions</description> <description>Frontend-agnostic library: protocol integration, model, audio abstractions</description>
<properties>
<!-- Android runs core too, and it only knows Java 17. -->
<maven.compiler.release>17</maven.compiler.release>
<!-- The TeamSpeak 3 client whose icon packs ship (-Dts3.client.dir=…); without one, none do. -->
<ts3.client.dir>${project.basedir}/../../TeamSpeak3-Client-linux_amd64</ts3.client.dir>
</properties>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>com.github.manevolent</groupId> <groupId>com.github.manevolent</groupId>
@@ -26,4 +33,23 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
</dependencies> </dependencies>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
<!-- TeamSpeak's own icon packs and country flags, proprietary and so taken from an install, not the repository. -->
<resource>
<directory>${ts3.client.dir}/gfx</directory>
<targetPath>gfx/teamspeak</targetPath>
<includes>
<include>default.zip</include>
<include>default_colored_2014.zip</include>
<include>default_mono_2014.zip</include>
<include>countries.zip</include>
</includes>
</resource>
</resources>
</build>
</project> </project>

View File

@@ -1,20 +1,36 @@
package com.ts3client.audio; package com.ts3client.audio;
import com.ts3client.audio.opus.OpusCodec;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.sound.SoundPlayer; import com.ts3client.sound.SoundPlayer;
import com.ts3client.sound.PackSoundPlayer;
/** /**
* Factory for a platform's voice capture and playback. Injected into the * A platform's audio stack: its devices and its libopus. Injected into the connection
* connection layer so the core stays independent of any concrete audio stack. * layer so the core stays independent of any concrete audio stack; the voice pipelines
* and the sound player on top are the same everywhere.
*/ */
public interface AudioBackend { public interface AudioBackend {
VoiceInput createInput(Settings settings); AudioIo io();
VoiceOutput createOutput(Settings settings); OpusCodec opus();
/** How incoming voice reaches this platform's devices. */
StreamingVoiceOutput.Lines outputLines();
default VoiceInput createInput(Settings settings) {
return new CaptureVoiceInput(settings, io(), opus());
}
default VoiceOutput createOutput(Settings settings) {
return new StreamingVoiceOutput(io(), opus(), settings.outputDevice, outputLines());
}
/** Player for notification sounds (sound packs); shared by all connections. */ /** Player for notification sounds (sound packs); shared by all connections. */
SoundPlayer createSoundPlayer(Settings settings); default SoundPlayer createSoundPlayer(Settings settings) {
return new PackSoundPlayer(io(), opus(), settings.outputDevice);
}
/** Human-readable codec/backend description, e.g. for an "about" line. */ /** Human-readable codec/backend description, e.g. for an "about" line. */
String description(); String description();

View File

@@ -1,11 +1,11 @@
package com.ts3client.audio.desktop; package com.ts3client.audio;
/** /**
* An open microphone line, delivering 16-bit little-endian PCM at * An open microphone line, delivering 16-bit little-endian PCM at
* {@link AudioDevices#SAMPLE_RATE}. * {@link VoiceFormat#SAMPLE_RATE}.
* *
* <p>Modelled on {@link javax.sound.sampled.TargetDataLine} so the capture pipeline does * <p>Modelled on Java Sound's {@code TargetDataLine} so the capture pipeline does
* not care whether the audio comes from PipeWire or Java Sound. * not care which platform audio API delivers it.
*/ */
public interface AudioCapture extends AutoCloseable { public interface AudioCapture extends AutoCloseable {

View File

@@ -0,0 +1,13 @@
package com.ts3client.audio;
/** A selectable audio device. {@code id} is what gets persisted in the settings. */
public record AudioDevice(String id, String label) {
/** The implicit entry that lets the platform choose. */
public static final AudioDevice DEFAULT = new AudioDevice("", "(System default)");
@Override
public String toString() {
return label;
}
}

View File

@@ -1,238 +0,0 @@
package com.ts3client.audio;
import java.util.Arrays;
/**
* Microphone pre-processing chain applied before voice activation and Opus encoding,
* mirroring the capture-side denoise/typing filters of the TeamSpeak&nbsp;3 client.
*
* <p>The stages run in the same order as the TeamSpeak client's WebRTC capture chain:
* a {@link HighPassFilter} (always-on rumble/DC removal), then a streaming short-time
* Fourier transform (square-root Hann window, 50% overlap-add) carrying a
* {@link NoiseSuppressor} ("Remove background noise") and a {@link TypingAttenuator}
* ("Typing attenuation") &mdash; sharing one FFT/IFFT per hop &mdash; and finally an
* {@link AutomaticGainControl} ("AGC"). Echo cancellation (WebRTC AEC3) is omitted as
* it requires the loudspeaker reference signal.
*
* <p>Input frames of any length are decoupled from the STFT hop by internal ring
* buffers; when noise/typing suppression is active the output is delayed by one hop
* (~5&nbsp;ms). The high-pass filter and AGC are zero-latency. When every stage is
* disabled the chain is fully bypassed and audio passes through untouched.
*
* <p>Pure DSP with no platform dependencies, so any frontend/backend can reuse it.
* Not thread-safe: drive it from a single capture thread; the enable/level setters
* are cheap volatiles safe to call from the UI thread.
*/
public final class AudioEnhancer {
private static final int FFT_SIZE = 512; // power of two -> 10.7 ms @ 48 kHz
private static final int HOP = FFT_SIZE / 2; // 50% overlap
private static final int BINS = FFT_SIZE / 2 + 1;
private final double[] window = new double[FFT_SIZE];
private final double[] re = new double[FFT_SIZE];
private final double[] im = new double[FFT_SIZE];
private final double[] power = new double[BINS];
private final double[] gain = new double[BINS];
private final double[] frame = new double[FFT_SIZE]; // sliding analysis frame
private final double[] ola = new double[FFT_SIZE]; // overlap-add accumulator
private final FloatRing input = new FloatRing(FFT_SIZE * 4);
private final FloatRing output = new FloatRing(FFT_SIZE * 4);
private final float[] hopIn = new float[HOP];
private final NoiseSuppressor noiseSuppressor = new NoiseSuppressor(BINS);
private final TypingAttenuator typingAttenuator;
private final HighPassFilter highPass;
private final AutomaticGainControl agc;
private volatile boolean noiseEnabled;
private volatile boolean typingEnabled;
private volatile boolean agcEnabled;
private boolean active; // any stage on: HPF + AGC state is live
private boolean stftRunning; // noise/typing on: STFT rings are live
public AudioEnhancer(int sampleRate) {
for (int i = 0; i < FFT_SIZE; i++) {
// sqrt(Hann): analysis*synthesis = Hann, which is COLA at 50% overlap.
window[i] = Math.sqrt(0.5 * (1 - Math.cos(2 * Math.PI * i / FFT_SIZE)));
}
this.typingAttenuator = new TypingAttenuator(BINS, sampleRate, FFT_SIZE);
this.highPass = new HighPassFilter(sampleRate);
this.agc = new AutomaticGainControl(sampleRate);
}
public void setNoiseSuppression(boolean enabled) {
this.noiseEnabled = enabled;
}
public void setDenoiserLevel(double level) {
noiseSuppressor.setLevel(level);
}
public void setTypingAttenuation(boolean enabled) {
this.typingEnabled = enabled;
}
public void setAgc(boolean enabled) {
this.agcEnabled = enabled;
}
/** Clears all filter state; call when (re)starting capture. */
public void reset() {
resetStft();
highPass.reset();
agc.reset();
active = false;
stftRunning = false;
}
private void resetStft() {
input.clear();
output.clear();
Arrays.fill(frame, 0);
Arrays.fill(ola, 0);
noiseSuppressor.reset();
typingAttenuator.reset();
}
/**
* Enhances one frame of mono PCM in place. {@code buf[0..len)} is overwritten with
* the processed (one-hop-delayed when noise/typing suppression is on) signal.
* Returns immediately if every stage is disabled.
*/
public void process(float[] buf, int len) {
boolean stft = noiseEnabled || typingEnabled;
boolean anyStage = stft || agcEnabled;
if (!anyStage) {
if (active) reset(); // drop stale filter/delay state on full disable
return;
}
if (!active) {
reset();
active = true;
}
// 1) High-pass filter (always-on part of the active chain).
highPass.process(buf, len);
// 2) STFT noise + typing suppression (only when either is enabled).
if (stft) {
if (!stftRunning) {
resetStft();
stftRunning = true;
}
runStft(buf, len);
} else if (stftRunning) {
stftRunning = false;
}
// 3) Automatic gain control (last, on the cleaned signal).
if (agcEnabled) {
agc.process(buf, len);
}
}
private void runStft(float[] buf, int len) {
input.write(buf, len);
while (input.available() >= HOP) {
System.arraycopy(frame, HOP, frame, 0, FFT_SIZE - HOP);
input.read(hopIn, HOP);
for (int i = 0; i < HOP; i++) {
frame[FFT_SIZE - HOP + i] = hopIn[i];
}
processBlock();
}
// During the initial one-hop priming the output ring is short; pad with zeros.
int ready = output.available();
if (ready < len) {
for (int i = 0; i < len - ready; i++) buf[i] = 0f;
output.read(buf, len - ready, ready);
} else {
output.read(buf, 0, len);
}
}
private void processBlock() {
for (int i = 0; i < FFT_SIZE; i++) {
re[i] = frame[i] * window[i];
im[i] = 0;
}
Fft.forward(re, im);
for (int k = 0; k < BINS; k++) {
power[k] = re[k] * re[k] + im[k] * im[k];
gain[k] = 1.0;
}
if (noiseEnabled) noiseSuppressor.apply(power, gain);
if (typingEnabled) typingAttenuator.apply(power, gain);
// Apply the real-valued gain to each bin and its conjugate mirror.
for (int k = 0; k < BINS; k++) {
double g = gain[k];
re[k] *= g;
im[k] *= g;
if (k > 0 && k < FFT_SIZE - k) {
int m = FFT_SIZE - k;
re[m] *= g;
im[m] *= g;
}
}
Fft.inverse(re, im);
for (int i = 0; i < FFT_SIZE; i++) {
ola[i] += re[i] * window[i];
}
output.write(ola, HOP);
System.arraycopy(ola, HOP, ola, 0, FFT_SIZE - HOP);
Arrays.fill(ola, FFT_SIZE - HOP, FFT_SIZE, 0);
}
/** Minimal single-producer/single-consumer float ring buffer. */
private static final class FloatRing {
private final float[] buf;
private int head, tail, size;
FloatRing(int capacity) {
this.buf = new float[capacity];
}
int available() {
return size;
}
void clear() {
head = tail = size = 0;
}
void write(float[] src, int len) {
for (int i = 0; i < len; i++) {
buf[tail] = src[i];
tail = (tail + 1) % buf.length;
}
size += len;
}
void write(double[] src, int len) {
for (int i = 0; i < len; i++) {
buf[tail] = (float) src[i];
tail = (tail + 1) % buf.length;
}
size += len;
}
void read(float[] dst, int len) {
read(dst, 0, len);
}
void read(float[] dst, int offset, int len) {
for (int i = 0; i < len; i++) {
dst[offset + i] = buf[head];
head = (head + 1) % buf.length;
}
size -= len;
}
}
}

View File

@@ -0,0 +1,17 @@
package com.ts3client.audio;
/** Receives frames of PCM audio as they pass through the capture or playback chain. */
@FunctionalInterface
public interface AudioFrameListener {
/**
* Called with one frame of interleaved samples in [-1, 1].
*
* <p>The array is reused between calls, so copy anything you need to keep. Called from
* the audio thread: do not block.
*
* @param interleaved samples, {@code channels} per sample position
* @param channels number of interleaved channels
*/
void onFrame(float[] interleaved, int channels);
}

View File

@@ -0,0 +1,23 @@
package com.ts3client.audio;
import java.util.List;
/** The platform's audio devices: what can be picked, and opening lines on them. */
public interface AudioIo {
/** Devices that can provide microphone lines, {@link AudioDevice#DEFAULT} first. */
List<AudioDevice> inputDevices();
/** Devices that can provide speaker lines, {@link AudioDevice#DEFAULT} first. */
List<AudioDevice> outputDevices();
/**
* Opens a capture line at {@link VoiceFormat#SAMPLE_RATE}, preferring
* {@code preferredChannels} but falling back to what the device takes; inspect
* {@link AudioCapture#channels()} for the result. An empty id means the default device.
*/
AudioCapture openCapture(String deviceId, int preferredChannels) throws Exception;
/** Opens a playback line; see {@link #openCapture} for the channel negotiation. */
AudioPlayback openPlayback(String deviceId, int preferredChannels) throws Exception;
}

View File

@@ -1,11 +1,11 @@
package com.ts3client.audio.desktop; package com.ts3client.audio;
/** /**
* An open speaker line, accepting 16-bit little-endian PCM at * An open speaker line, accepting 16-bit little-endian PCM at
* {@link AudioDevices#SAMPLE_RATE}. * {@link VoiceFormat#SAMPLE_RATE}.
* *
* <p>Modelled on {@link javax.sound.sampled.SourceDataLine} so the playback pipeline does * <p>Modelled on Java Sound's {@code SourceDataLine} so the playback pipeline does
* not care whether the audio goes to PipeWire or Java Sound. * not care which platform audio API plays it.
*/ */
public interface AudioPlayback extends AutoCloseable { public interface AudioPlayback extends AutoCloseable {
@@ -15,7 +15,12 @@ public interface AudioPlayback extends AutoCloseable {
/** Begins playing whatever is written from now on. */ /** Begins playing whatever is written from now on. */
void start(); void start();
/** Blocks until the audio has been queued for playback. */ /**
* Blocks until the audio has been queued for playback.
*
* @throws IllegalStateException once the line is gone for good (its device went away,
* say), so the caller can open another
*/
void write(byte[] buffer, int offset, int length); void write(byte[] buffer, int offset, int length);
/** Blocks until everything already written has been played out. */ /** Blocks until everything already written has been played out. */

View File

@@ -1,66 +0,0 @@
package com.ts3client.audio;
/**
* Automatic gain control &mdash; WebRTC APM's {@code gain_controller} (AGC2 adaptive
* digital) / Speex {@code AGC} stage. It normalises voice loudness toward a target
* level so quiet microphones are boosted and loud ones tamed, keeping perceived volume
* consistent across speakers.
*
* <p>Placed last in the capture chain (after noise suppression), it tracks the frame
* level and moves an applied gain toward {@code target / level}: it attenuates quickly
* to head off clipping and boosts slowly to avoid pumping. A noise gate freezes the
* gain while the input is near silence, so background noise between words is never
* amplified; the per-sample gain ramp avoids zipper artefacts and a final clamp guards
* against overshoot.
*/
final class AutomaticGainControl {
private static final double TARGET_RMS = 0.12; // ~ -18.4 dBFS
private static final double MAX_GAIN = dbToGain(30); // up to +30 dB boost
private static final double MIN_GAIN = dbToGain(-20); // down to -20 dB
private static final double NOISE_GATE_RMS = dbToGain(-55); // freeze below this level
private final double attackCoeff; // gain decreasing (signal too loud): fast
private final double releaseCoeff; // gain increasing (too quiet): slow
private double gain = 1.0;
AutomaticGainControl(int sampleRate) {
this.attackCoeff = 1 - Math.exp(-1.0 / (0.005 * sampleRate)); // ~5 ms
this.releaseCoeff = 1 - Math.exp(-1.0 / (0.300 * sampleRate)); // ~300 ms
}
void reset() {
gain = 1.0;
}
/** Applies gain normalisation to one mono frame in place. */
void process(float[] buf, int len) {
double sumSq = 0;
for (int i = 0; i < len; i++) {
sumSq += (double) buf[i] * buf[i];
}
double rms = Math.sqrt(sumSq / len);
double desired = gain;
if (rms >= NOISE_GATE_RMS) {
desired = TARGET_RMS / rms;
if (desired > MAX_GAIN) desired = MAX_GAIN;
else if (desired < MIN_GAIN) desired = MIN_GAIN;
}
// Boost slowly, attenuate quickly.
double coeff = desired < gain ? attackCoeff : releaseCoeff;
for (int i = 0; i < len; i++) {
gain += (desired - gain) * coeff;
double y = buf[i] * gain;
if (y > 1.0) y = 1.0;
else if (y < -1.0) y = -1.0;
buf[i] = (float) y;
}
}
private static double dbToGain(double db) {
return Math.pow(10.0, db / 20.0);
}
}

View File

@@ -1,10 +1,11 @@
package com.ts3client.audio.desktop; package com.ts3client.audio;
import com.github.manevolent.ts3j.enums.CodecType; import com.github.manevolent.ts3j.enums.CodecType;
import com.ts3client.audio.AudioEnhancer; import com.ts3client.audio.opus.OpusCodec;
import com.ts3client.audio.OpusParameters; import com.ts3client.audio.opus.OpusEncoder;
import com.ts3client.audio.SpeechDetector; import com.ts3client.audio.processing.AudioProcessor;
import com.ts3client.audio.VoiceInput; import com.ts3client.audio.processing.ns.SuppressionLevel;
import com.ts3client.audio.vad.RnnSpeechDetector;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentLinkedQueue;
@@ -12,11 +13,11 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer; import java.util.function.Consumer;
/** /**
* Desktop {@link VoiceInput}: captures the microphone, applies voice-activation * The {@link VoiceInput} every platform shares: captures the microphone, applies voice-activation
* or push-to-talk gating, and Opus-encodes 20&nbsp;ms frames. * or push-to-talk gating, and Opus-encodes 20&nbsp;ms frames.
* *
* <p>The capture line comes from {@link AudioDevices}, which picks PipeWire or Java * <p>The capture line comes from the platform's {@link AudioIo}; it is opened in stereo
* Sound; it is opened in stereo when the device offers it. Voice * when the device offers it. Voice
* ({@code OPUS_VOICE}) is transmitted mono, from the downmix, so the pre-processing * ({@code OPUS_VOICE}) is transmitted mono, from the downmix, so the pre-processing
* chain and VAD see a single channel; the music codec ({@code OPUS_MUSIC}) transmits * chain and VAD see a single channel; the music codec ({@code OPUS_MUSIC}) transmits
* the stereo capture as-is. * the stereo capture as-is.
@@ -26,12 +27,31 @@ import java.util.function.Consumer;
* network sending. When the gate closes and the queue drains {@link #isReady()} * network sending. When the gate closes and the queue drains {@link #isReady()}
* returns {@code false}, prompting ts3j to emit the terminating empty voice packet. * returns {@code false}, prompting ts3j to emit the terminating empty voice packet.
*/ */
public final class DesktopVoiceInput implements VoiceInput { public final class CaptureVoiceInput implements VoiceInput {
private static final int HANGOVER_FRAMES = 15; // ~300 ms of tail after level drops /**
* How long the gate stays open after the last active frame. TS3 counts 64 of its
* 10&nbsp;ms preprocessor frames before closing, so the tail of a word is never clipped.
*/
private static final int HANGOVER_MS = 640;
/**
* Audio replayed when the gate opens, so a word's onset is not swallowed by the frame
* that detected it. TS3 keeps up to three 10&nbsp;ms buffers ({@code vad_extrabuffersize}
* defaults to 2, plus one).
*/
private static final int PREROLL_MS = 30;
private static final int HANGOVER_FRAMES =
Math.max(1, HANGOVER_MS * VoiceFormat.SAMPLE_RATE / 1000 / VoiceFormat.FRAME_SIZE);
private static final int PREROLL_FRAMES =
Math.max(1, PREROLL_MS * VoiceFormat.SAMPLE_RATE / 1000 / VoiceFormat.FRAME_SIZE);
/** How long stop() waits for the capture loop to finish its frame. */
private static final long STOP_TIMEOUT_MS = 500;
private final ConcurrentLinkedQueue<byte[]> queue = new ConcurrentLinkedQueue<>(); private final ConcurrentLinkedQueue<byte[]> queue = new ConcurrentLinkedQueue<>();
private final AtomicBoolean muted = new AtomicBoolean(false); private final AtomicBoolean muted = new AtomicBoolean(false);
private final AtomicBoolean localMuted = new AtomicBoolean(false);
private final AtomicBoolean transmitting = new AtomicBoolean(false); private final AtomicBoolean transmitting = new AtomicBoolean(false);
private final AtomicBoolean pttDown = new AtomicBoolean(false); private final AtomicBoolean pttDown = new AtomicBoolean(false);
private final AtomicBoolean running = new AtomicBoolean(false); private final AtomicBoolean running = new AtomicBoolean(false);
@@ -44,20 +64,24 @@ public final class DesktopVoiceInput implements VoiceInput {
private volatile double inputGain; private volatile double inputGain;
private volatile CodecType codec = CodecType.OPUS_VOICE; private volatile CodecType codec = CodecType.OPUS_VOICE;
private final SpeechDetector speechDetector = new SpeechDetector(AudioDevices.SAMPLE_RATE); private final SpeechProbabilityDetector speechDetector =
private final AudioEnhancer enhancer = new AudioEnhancer(AudioDevices.SAMPLE_RATE); new RnnSpeechDetector(VoiceFormat.SAMPLE_RATE);
private final AudioProcessor processor = new AudioProcessor();
private volatile Consumer<Double> levelListener; // input level in dBFS private volatile Consumer<Double> levelListener; // input level, InputLevel scale
private volatile Consumer<Boolean> talkListener; // local talk-state changes private volatile Consumer<Boolean> talkListener; // local talk-state changes
private volatile Runnable mutedTalkListener; // speech detected while muted private volatile Runnable mutedTalkListener; // speech detected while muted
private volatile AudioFrameListener monitorListener; // local monitoring of what is sent
private boolean mutedTalking; private boolean mutedTalking;
private final String deviceName; private final AudioIo io;
private final OpusCodec opus;
private volatile String deviceName;
private final Object encoderLock = new Object(); private final Object encoderLock = new Object();
private volatile OpusParameters params; private volatile OpusParameters params;
private OpusParameters appliedParams; private OpusParameters appliedParams;
private int encoderApplication = -1; private OpusCodec.Application encoderApplication;
private volatile int encoderChannels = 1; private volatile int encoderChannels = 1;
private volatile int captureChannels = 1; private volatile int captureChannels = 1;
@@ -68,7 +92,14 @@ public final class DesktopVoiceInput implements VoiceInput {
private int hangover; private int hangover;
private boolean lastTransmitting; private boolean lastTransmitting;
public DesktopVoiceInput(Settings settings) { // Ring of recent frames captured while the gate was shut.
private final float[][] preroll = new float[PREROLL_FRAMES][];
private int prerollTail;
private int prerollCount;
public CaptureVoiceInput(Settings settings, AudioIo io, OpusCodec opus) {
this.io = io;
this.opus = opus;
this.deviceName = settings.inputDevice; this.deviceName = settings.inputDevice;
this.mode = settings.inputMode; this.mode = settings.inputMode;
this.vadMode = settings.vadMode; this.vadMode = settings.vadMode;
@@ -78,14 +109,26 @@ public final class DesktopVoiceInput implements VoiceInput {
this.inputGain = settings.inputVolume; this.inputGain = settings.inputVolume;
this.params = OpusParameters.from(settings); this.params = OpusParameters.from(settings);
this.codec = codecFor(params); this.codec = codecFor(params);
enhancer.setNoiseSuppression(settings.denoise); processor.setNoiseSuppression(settings.denoise);
enhancer.setDenoiserLevel(settings.denoiserLevel); processor.setSuppressionLevel(SuppressionLevel.fromDenoiserLevel(settings.denoiserLevel));
enhancer.setTypingAttenuation(settings.typingAttenuation); processor.setTransientSuppression(settings.typingAttenuation);
enhancer.setAgc(settings.agc); processor.setGainControl(settings.agc);
processor.setGainBoost(settings.agcBoostDb);
} }
// ---- live configuration (safe to call from the UI thread) ---- // ---- live configuration (safe to call from the UI thread) ----
@Override
public synchronized void setInputDevice(String deviceId) {
String device = deviceId == null ? "" : deviceId;
if (device.equals(deviceName)) return;
deviceName = device;
if (running.get()) {
stop();
start();
}
}
public void setMode(Settings.InputMode mode) { public void setMode(Settings.InputMode mode) {
this.mode = mode; this.mode = mode;
} }
@@ -112,19 +155,28 @@ public final class DesktopVoiceInput implements VoiceInput {
} }
public void setNoiseSuppression(boolean enabled) { public void setNoiseSuppression(boolean enabled) {
enhancer.setNoiseSuppression(enabled); processor.setNoiseSuppression(enabled);
} }
public void setDenoiserLevel(double level) { public void setDenoiserLevel(int level) {
enhancer.setDenoiserLevel(level); processor.setSuppressionLevel(SuppressionLevel.fromDenoiserLevel(level));
} }
public void setTypingAttenuation(boolean enabled) { public void setTypingAttenuation(boolean enabled) {
enhancer.setTypingAttenuation(enabled); processor.setTransientSuppression(enabled);
} }
public void setAgc(boolean enabled) { public void setAgc(boolean enabled) {
enhancer.setAgc(enabled); processor.setGainControl(enabled);
}
public void setAgcBoost(int db) {
processor.setGainBoost(db);
}
@Override
public void keyPressed() {
processor.keyPressed();
} }
/** /**
@@ -140,8 +192,8 @@ public final class DesktopVoiceInput implements VoiceInput {
} }
} }
private static int applicationFor(OpusParameters p) { private static OpusCodec.Application applicationFor(OpusParameters p) {
return p.music ? Opus.OPUS_APPLICATION_AUDIO : Opus.OPUS_APPLICATION_VOIP; return p.music ? OpusCodec.Application.AUDIO : OpusCodec.Application.VOIP;
} }
private static CodecType codecFor(OpusParameters p) { private static CodecType codecFor(OpusParameters p) {
@@ -158,13 +210,13 @@ public final class DesktopVoiceInput implements VoiceInput {
/** Creates or reconfigures the encoder to match {@code p}. Call under {@link #encoderLock}. */ /** Creates or reconfigures the encoder to match {@code p}. Call under {@link #encoderLock}. */
private void applyParams(OpusParameters p) { private void applyParams(OpusParameters p) {
int application = applicationFor(p); OpusCodec.Application application = applicationFor(p);
int channels = channelsFor(p); int channels = channelsFor(p);
// Application (VOIP vs AUDIO) and channel count are fixed at creation, so // Application (VOIP vs AUDIO) and channel count are fixed at creation, so
// switching voice<->music means building a new encoder. // switching voice<->music means building a new encoder.
if (encoder == null || application != encoderApplication || channels != encoderChannels) { if (encoder == null || application != encoderApplication || channels != encoderChannels) {
OpusEncoder replacement = new OpusEncoder( OpusEncoder replacement = opus.createEncoder(
AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, channels, application); VoiceFormat.SAMPLE_RATE, VoiceFormat.FRAME_SIZE, channels, application);
configureEncoder(replacement, p); configureEncoder(replacement, p);
OpusEncoder previous = encoder; OpusEncoder previous = encoder;
encoder = replacement; encoder = replacement;
@@ -184,7 +236,7 @@ public final class DesktopVoiceInput implements VoiceInput {
enc.setVbr(p.vbr); enc.setVbr(p.vbr);
enc.setInbandFec(p.fec); enc.setInbandFec(p.fec);
enc.setExpectedPacketLoss(p.expectedPacketLoss); enc.setExpectedPacketLoss(p.expectedPacketLoss);
enc.setSignal(p.music ? Opus.OPUS_SIGNAL_MUSIC : Opus.OPUS_SIGNAL_VOICE); enc.setSignal(p.music ? OpusEncoder.Signal.MUSIC : OpusEncoder.Signal.VOICE);
} }
public void setLevelListener(Consumer<Double> l) { public void setLevelListener(Consumer<Double> l) {
@@ -195,6 +247,11 @@ public final class DesktopVoiceInput implements VoiceInput {
this.talkListener = l; this.talkListener = l;
} }
@Override
public void setMonitorListener(AudioFrameListener l) {
this.monitorListener = l;
}
@Override @Override
public void setMutedTalkListener(Runnable l) { public void setMutedTalkListener(Runnable l) {
this.mutedTalkListener = l; this.mutedTalkListener = l;
@@ -208,12 +265,22 @@ public final class DesktopVoiceInput implements VoiceInput {
this.muted.set(m); this.muted.set(m);
} }
@Override
public void setLocalMuted(boolean m) {
this.localMuted.set(m);
}
@Override
public boolean isLocalMuted() {
return localMuted.get();
}
// ---- lifecycle ---- // ---- lifecycle ----
public synchronized void start() { public synchronized void start() {
if (running.get()) return; if (running.get()) return;
try { try {
line = AudioDevices.openCapture(deviceName, AudioDevices.MAX_CHANNELS); line = io.openCapture(deviceName, VoiceFormat.MAX_CHANNELS);
captureChannels = line.channels(); captureChannels = line.channels();
synchronized (encoderLock) { synchronized (encoderLock) {
applyParams(params); applyParams(params);
@@ -223,7 +290,7 @@ public final class DesktopVoiceInput implements VoiceInput {
throw new RuntimeException("Could not start microphone: " + t.getMessage(), t); throw new RuntimeException("Could not start microphone: " + t.getMessage(), t);
} }
speechDetector.reset(); speechDetector.reset();
enhancer.reset(); processor.reset();
running.set(true); running.set(true);
captureThread = new Thread(this::captureLoop, "ts3j-mic-capture"); captureThread = new Thread(this::captureLoop, "ts3j-mic-capture");
captureThread.setDaemon(true); captureThread.setDaemon(true);
@@ -232,9 +299,20 @@ public final class DesktopVoiceInput implements VoiceInput {
public synchronized void stop() { public synchronized void stop() {
running.set(false); running.set(false);
if (captureThread != null) { Thread capture = captureThread;
captureThread.interrupt();
captureThread = null; captureThread = null;
if (line != null) {
line.close(); // wakes the capture loop's blocking read
line = null;
}
if (capture != null) {
// Let the loop finish its frame, so it cannot reopen the gate or overlap a restart.
capture.interrupt();
try {
capture.join(STOP_TIMEOUT_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
} }
cleanup(); cleanup();
queue.clear(); queue.clear();
@@ -253,14 +331,14 @@ public final class DesktopVoiceInput implements VoiceInput {
} catch (Exception ignored) { } catch (Exception ignored) {
} }
encoder = null; encoder = null;
encoderApplication = -1; encoderApplication = null;
appliedParams = null; appliedParams = null;
} }
} }
} }
private void captureLoop() { private void captureLoop() {
final int frameSamples = AudioDevices.FRAME_SIZE; final int frameSamples = VoiceFormat.FRAME_SIZE;
final int channels = captureChannels; final int channels = captureChannels;
final byte[] buf = new byte[frameSamples * 2 * channels]; final byte[] buf = new byte[frameSamples * 2 * channels];
final float[] pcm = new float[frameSamples * channels]; // interleaved capture final float[] pcm = new float[frameSamples * channels]; // interleaved capture
@@ -300,41 +378,92 @@ public final class DesktopVoiceInput implements VoiceInput {
if (p != appliedParams && encoder != null) applyParams(p); if (p != appliedParams && encoder != null) applyParams(p);
boolean stereo = encoderChannels == 2; boolean stereo = encoderChannels == 2;
// Denoise / typing attenuation are voice-chain stages: they feed the // As in TS3, the speech detector judges the raw microphone signal, while
// level meter, VAD and encoder alike on the mono path. A stereo (music) // the level meter and volume gate see the processed one.
// stream bypasses them and is transmitted as captured. double probability = usesSpeechDetector()
if (!stereo) enhancer.process(mono, frameSamples); ? speechDetector.process(mono, frameSamples) : 0.0;
double sumSq = 0; // Processing is a voice-chain stage on the mono path. A stereo (music)
for (int i = 0; i < frameSamples; i++) { // stream bypasses it and is transmitted as captured.
sumSq += (double) mono[i] * mono[i]; if (!stereo) processor.process(mono, frameSamples);
}
double rms = Math.sqrt(sumSq / frameSamples); double db = InputLevel.toDb(mono, frameSamples);
double db = (rms <= 1e-9) ? -100.0 : 20.0 * Math.log10(rms);
Consumer<Double> ll = levelListener; Consumer<Double> ll = levelListener;
if (ll != null) ll.accept(db); if (ll != null) ll.accept(db);
boolean open = decideGate(db, mono); boolean wasOpen = transmitting.get();
boolean open = decideGate(db, probability);
setTransmitting(open); setTransmitting(open);
if (open && !muted.get() && encoder != null) { if (open && !muted.get() && encoder != null) {
try { try {
packet = encoder.encode(stereo ? pcm : mono); // On the opening edge, send the buffered lead-in first so the
// word's onset isn't lost to the frame that detected it.
if (!wasOpen) {
flushPreroll(stereo);
}
float[] sent = stereo ? pcm : mono;
packet = encoder.encode(sent);
monitor(sent, stereo ? encoderChannels : 1);
} catch (Exception ignored) { } catch (Exception ignored) {
} }
} }
if (!open) {
rememberForPreroll(stereo ? pcm : mono);
}
} }
if (packet != null && packet.length > 0) { if (packet != null && packet.length > 0) {
queue.offer(packet); offer(packet);
// Guard against unbounded growth if the network stalls.
while (queue.size() > 10) queue.poll();
} }
} }
} }
private boolean decideGate(double db, float[] pcm) { private void offer(byte[] packet) {
if (muted.get()) { queue.offer(packet);
// Guard against unbounded growth if the network stalls.
while (queue.size() > 10) queue.poll();
}
/** Keeps the most recent frames while the gate is shut, for {@link #flushPreroll}. */
private void rememberForPreroll(float[] frame) {
float[] slot = preroll[prerollTail];
if (slot == null || slot.length != frame.length) {
slot = new float[frame.length];
preroll[prerollTail] = slot;
}
System.arraycopy(frame, 0, slot, 0, frame.length);
prerollTail = (prerollTail + 1) % PREROLL_FRAMES;
if (prerollCount < PREROLL_FRAMES) prerollCount++;
}
/** Encodes and queues the buffered lead-in, oldest first. Call under {@link #encoderLock}. */
private void flushPreroll(boolean stereo) {
int expected = stereo ? VoiceFormat.FRAME_SIZE * encoderChannels : VoiceFormat.FRAME_SIZE;
for (int i = 0; i < prerollCount; i++) {
int idx = (prerollTail - prerollCount + i + PREROLL_FRAMES) % PREROLL_FRAMES;
float[] frame = preroll[idx];
// A channel-count change between capture and flush invalidates the buffer.
if (frame == null || frame.length != expected) continue;
try {
byte[] p = encoder.encode(frame);
if (p != null && p.length > 0) offer(p);
monitor(frame, stereo ? encoderChannels : 1);
} catch (Exception ignored) {
}
}
prerollCount = 0;
}
/** Whether the gate may consult the speech detector, so it must follow the signal. */
private boolean usesSpeechDetector() {
if (vadMode == Settings.VadMode.VOLUME_GATE) return false;
Settings.InputMode m = mode;
return m == Settings.InputMode.VOICE_ACTIVATION || (m == Settings.InputMode.PUSH_TO_TALK && vadOverPtt);
}
private boolean decideGate(double db, double probability) {
if (muted.get() || localMuted.get()) {
hangover = 0; hangover = 0;
detectMutedSpeech(db); detectMutedSpeech(db);
return false; return false;
@@ -345,32 +474,25 @@ public final class DesktopVoiceInput implements VoiceInput {
return true; return true;
case PUSH_TO_TALK: case PUSH_TO_TALK:
if (pttDown.get()) return true; if (pttDown.get()) return true;
return vadOverPtt && voiceActivated(db, pcm); return vadOverPtt && voiceActivated(db, probability);
case VOICE_ACTIVATION: case VOICE_ACTIVATION:
default: default:
return voiceActivated(db, pcm); return voiceActivated(db, probability);
} }
} }
/** /**
* Applies the selected VAD mode (volume gate, speech probability, or both) with * Applies the selected VAD mode with a hangover so trailing syllables aren't clipped.
* a deactivation-delay hangover so trailing syllables aren't clipped. *
* <p>The modes match the TS3 client's: the volume gate alone, the speech detector
* alone, or both together. Volume Gate skips the detector entirely, as TS3 does.
*/ */
private boolean voiceActivated(double db, float[] pcm) { private boolean voiceActivated(double db, double probability) {
boolean detected; boolean detected = switch (vadMode) {
switch (vadMode) { case VOLUME_GATE -> db >= thresholdDb;
case VOLUME_GATE: case AUTOMATIC -> probability >= speechThreshold;
detected = db >= thresholdDb; case HYBRID -> db >= thresholdDb && probability >= speechThreshold;
break; };
case AUTOMATIC:
detected = speechDetector.process(pcm) >= speechThreshold;
break;
case HYBRID:
default:
double probability = speechDetector.process(pcm);
detected = db >= thresholdDb && probability >= speechThreshold;
break;
}
if (detected) { if (detected) {
hangover = HANGOVER_FRAMES; hangover = HANGOVER_FRAMES;
return true; return true;
@@ -395,6 +517,12 @@ public final class DesktopVoiceInput implements VoiceInput {
mutedTalking = talking; mutedTalking = talking;
} }
/** Hands a transmitted frame to the monitor, if one is attached. */
private void monitor(float[] frame, int channels) {
AudioFrameListener l = monitorListener;
if (l != null) l.onFrame(frame, channels);
}
private void setTransmitting(boolean t) { private void setTransmitting(boolean t) {
transmitting.set(t); transmitting.set(t);
if (t != lastTransmitting) { if (t != lastTransmitting) {

View File

@@ -4,18 +4,18 @@ package com.ts3client.audio;
* In-place iterative radix-2 Cooley&ndash;Tukey FFT shared by the voice DSP stages. * In-place iterative radix-2 Cooley&ndash;Tukey FFT shared by the voice DSP stages.
* All arrays must have a power-of-two length. Pure math, no platform dependencies. * All arrays must have a power-of-two length. Pure math, no platform dependencies.
*/ */
final class Fft { public final class Fft {
private Fft() { private Fft() {
} }
/** Forward transform (unnormalised). */ /** Forward transform (unnormalised). */
static void forward(double[] re, double[] im) { public static void forward(double[] re, double[] im) {
transform(re, im, false); transform(re, im, false);
} }
/** Inverse transform, normalised by {@code 1/n} so it inverts {@link #forward}. */ /** Inverse transform, normalised by {@code 1/n} so it inverts {@link #forward}. */
static void inverse(double[] re, double[] im) { public static void inverse(double[] re, double[] im) {
transform(re, im, true); transform(re, im, true);
int n = re.length; int n = re.length;
double scale = 1.0 / n; double scale = 1.0 / n;

View File

@@ -1,43 +0,0 @@
package com.ts3client.audio;
/**
* Second-order Butterworth high-pass filter (RBJ biquad, transposed direct form II).
* Mirrors WebRTC APM's {@code high_pass_filter} stage &mdash; an always-on part of the
* capture chain that removes DC offset, mains hum and low-frequency rumble below the
* speech band before noise suppression sees the signal.
*/
final class HighPassFilter {
private static final double CUTOFF_HZ = 80.0; // WebRTC APM high-pass cutoff
private final double b0, b1, b2, a1, a2;
private double z1, z2;
HighPassFilter(int sampleRate) {
double w0 = 2 * Math.PI * CUTOFF_HZ / sampleRate;
double cos = Math.cos(w0);
double alpha = Math.sin(w0) / Math.sqrt(2.0); // Q = 1/sqrt(2) (Butterworth)
double a0 = 1 + alpha;
this.b0 = (1 + cos) / 2 / a0;
this.b1 = -(1 + cos) / a0;
this.b2 = (1 + cos) / 2 / a0;
this.a1 = -2 * cos / a0;
this.a2 = (1 - alpha) / a0;
}
void reset() {
z1 = 0;
z2 = 0;
}
/** Filters one mono frame in place. */
void process(float[] buf, int len) {
for (int i = 0; i < len; i++) {
double x = buf[i];
double y = b0 * x + z1;
z1 = b1 * x - a1 * y + z2;
z2 = b2 * x - a2 * y;
buf[i] = (float) y;
}
}
}

View File

@@ -0,0 +1,58 @@
package com.ts3client.audio;
/**
* Capture level on the scale the TeamSpeak 3 client puts on its voice-activation slider.
*
* <p>TS3 stores {@code voiceactivation_level} in decibels but compares powers: its setter
* converts the slider value with {@code 10^(dB/10) * 0.0095} and its getter reports a
* measured power back with {@code 10*log10(power / 0.0095)}. The same reference constant is
* used here so a threshold of &minus;40 means the same thing it does in TeamSpeak.
*
* <p>The resulting scale runs from {@link #MIN_DB} to {@link #MAX_DB}, with 0&nbsp;dB
* corresponding to roughly &minus;20&nbsp;dBFS and the default &minus;40 to about
* &minus;60&nbsp;dBFS.
*/
public final class InputLevel {
/**
* Mean-square level that reads as 0 dB, taken from the client's own dB conversion.
*/
private static final double REFERENCE_POWER = 0.0095;
/** Lower end of the slider range. */
public static final double MIN_DB = -50.0;
/** Upper end of the slider range. */
public static final double MAX_DB = 50.0;
/** Reported when the frame is digital silence. */
public static final double SILENCE_DB = MIN_DB;
private InputLevel() {
}
/** Level of {@code length} mono samples in [-1, 1], in dB on the scale above. */
public static double toDb(float[] frame, int length) {
if (length <= 0) {
return SILENCE_DB;
}
double sumSq = 0;
for (int i = 0; i < length; i++) {
sumSq += (double) frame[i] * frame[i];
}
return powerToDb(sumSq / length);
}
/** Converts a mean-square power to the slider's dB scale. */
public static double powerToDb(double power) {
if (power <= 0) {
return SILENCE_DB;
}
return Math.max(SILENCE_DB, 10.0 * Math.log10(power / REFERENCE_POWER));
}
/** Inverse of {@link #powerToDb}, matching the client's threshold conversion. */
public static double dbToPower(double db) {
return Math.pow(10.0, db / 10.0) * REFERENCE_POWER;
}
}

View File

@@ -0,0 +1,584 @@
package com.ts3client.audio;
/**
* Adaptive jitter buffer for incoming voice packets.
*
* <p>This is a port of the jitter buffer TeamSpeak itself uses: the official client links
* libspeex's {@code libspeex/jitter.c} verbatim (its diagnostics are still in the shipped
* binary) and drives it through the wrapper described in {@link #forTeamSpeakVoice}. The
* algorithm is what makes a voice stream survive a bad network: packets are reordered by
* timestamp, losses are reported so the codec can conceal them, and — most importantly —
* the playback delay continuously re-tunes itself to the measured jitter instead of being
* a fixed guess.
*
* <h2>How the delay adapts</h2>
* Every packet's arrival is recorded as a <em>timing</em>: how early (positive) or late
* (negative) it was relative to the moment it was needed. Timings are kept in three
* rotating sub-windows, each holding only the {@value #MAX_TIMINGS} <em>latest</em> arrivals
* it saw, which makes the set a cheap running estimate of the distribution's late tail.
* {@link #computeOptimalDelay()} then walks the {@value #TOP_DELAY} latest timings and picks
* the delay minimising {@code delay + lateCost * (packets that would then be late)}. The
* result is applied a whole {@link #delayStep} at a time, by dropping a frame to shrink the
* delay or inserting a concealed one to grow it, so the correction is inaudible.
*
* <h2>Timestamp units</h2>
* Timestamps and spans are in caller-defined ticks; the buffer never converts them to time.
* All comparisons are wrap-safe, so a tick counter may overflow freely.
*
* <p>Not thread-safe: every call must be externally serialised.
*/
public final class JitterBuffer {
/** Outcome of a {@link #get} call. */
public enum Status {
/** A packet was available; see {@link #payload()}. */
OK,
/** Nothing available — the caller should conceal {@link #span()} ticks of loss. */
MISSING,
/** The buffer is growing its delay; the caller should insert {@link #span()} ticks. */
INSERTION
}
/** How many packets may be held at once, awaiting playback or reordering. */
private static final int MAX_PACKETS = 200;
/** Timings kept per sub-window. Only the latest arrivals are retained. */
private static final int MAX_TIMINGS = 40;
/** Rotating sub-windows the timings are spread over. */
private static final int SUB_WINDOWS = 3;
/** How many of the latest timings the delay search considers. */
private static final int TOP_DELAY = 40;
/**
* The latest arrival timings seen within one sub-window, sorted ascending.
*
* <p>Only the {@value #MAX_TIMINGS} latest are kept: an arrival too early to displace
* any of them is counted but not stored, since the delay search only ever looks at the
* late tail.
*/
private static final class TimingWindow {
final int[] timing = new int[MAX_TIMINGS];
/** Timings actually stored. */
int filled;
/** Arrivals seen, including those too early to store. */
int seen;
void clear() {
filled = 0;
seen = 0;
}
void add(int value) {
if (filled >= MAX_TIMINGS && value >= timing[filled - 1]) {
seen++;
return;
}
int pos = 0;
while (pos < filled && value >= timing[pos]) pos++;
if (pos < filled) {
int moveSize = filled - pos;
if (filled == MAX_TIMINGS) moveSize--;
System.arraycopy(timing, pos, timing, pos + 1, moveSize);
}
timing[pos] = value;
seen++;
if (filled < MAX_TIMINGS) filled++;
}
}
private final byte[][] data = new byte[MAX_PACKETS][];
private final int[] timestamp = new int[MAX_PACKETS];
private final int[] span = new int[MAX_PACKETS];
/** Value of {@link #nextStop} when the packet arrived, or 0 if that is unknown. */
private final int[] arrival = new int[MAX_PACKETS];
private final TimingWindow[] windows = new TimingWindow[SUB_WINDOWS];
/** {@link #windows} in age order, newest first; rotated in place. */
private final TimingWindow[] byAge = new TimingWindow[SUB_WINDOWS];
private final int delayStep;
private final int concealmentSize;
private int bufferMargin;
private int maxLateRate;
private int lateCost;
private int windowSize;
private int subWindowSize;
/** Playback position: the timestamp the next {@link #get} wants. */
private int pointerTimestamp;
/** Timestamp playback will have reached by the time the next packet is needed. */
private int nextStop;
/** Ticks handed out by the last {@link #get} beyond what was asked for. */
private int buffered;
/** Ticks of concealment {@link #get} has been told to insert before reading on. */
private int interpolationRequested;
/** Consecutive {@link #get} calls that found nothing. */
private int lostCount;
/** True until the first packet arrives and defines the timeline. */
private boolean resetState;
/** Self-tuned cost of a late packet, used when {@link #lateCost} is 0. */
private int autoTradeoff;
// Result of the most recent get(), kept here so the per-frame path allocates nothing.
private byte[] resultPayload;
private int resultTimestamp;
private int resultSpan;
/**
* @param stepSize granularity of delay changes, and the span of one concealed frame;
* normally the span of a single packet
* @param margin extra delay held on top of the measured optimum
* @param maxLateRate percentage of packets allowed to arrive too late, which also sets
* how long a window the delay is estimated over
* @param lateCost cost of one late packet relative to delay, or 0 to self-tune
*/
public JitterBuffer(int stepSize, int margin, int maxLateRate, int lateCost) {
this.delayStep = stepSize;
this.concealmentSize = stepSize;
this.bufferMargin = margin;
this.lateCost = lateCost;
setMaxLateRate(maxLateRate);
for (int i = 0; i < SUB_WINDOWS; i++) {
windows[i] = new TimingWindow();
byAge[i] = windows[i];
}
reset();
}
/**
* A buffer configured the way the official TeamSpeak client configures its own.
*
* <p>Recovered from {@code ts3client_linux_amd64} (3.6.x): it calls
* {@code jitter_buffer_init(60)} and then sets margin 60 and max late rate 1, pulling
* one 60-tick frame per {@code jitter_buffer_get}. One tick is therefore a sixtieth of
* a 20&nbsp;ms voice frame, so the delay moves in whole-frame steps, one frame of slack
* is held on top of the measured optimum, and the target is for no more than 1% of
* packets to arrive late — a deliberately conservative setting that estimates over a
* ~1.3&nbsp;s window and is the reason TeamSpeak rides out network trouble smoothly.
*
* @param ticksPerFrame ticks spanned by one voice frame; see {@link #TEAMSPEAK_TICKS_PER_FRAME}
*/
public static JitterBuffer forTeamSpeakVoice(int ticksPerFrame) {
return new JitterBuffer(ticksPerFrame, ticksPerFrame, 1, 0);
}
/** Ticks per voice frame in TeamSpeak's own timebase, i.e. a 20 ms frame in 1/3 ms units. */
public static final int TEAMSPEAK_TICKS_PER_FRAME = 60;
/**
* Sets the share of packets allowed to arrive too late, as a percentage.
*
* <p>This doubles as the estimator's time constant: a stricter rate needs a
* proportionally longer window to observe that many late packets.
*/
public void setMaxLateRate(int rate) {
this.maxLateRate = Math.max(1, rate);
this.windowSize = 100 * TOP_DELAY / this.maxLateRate;
this.subWindowSize = this.windowSize / SUB_WINDOWS;
}
/** Sets the extra delay held on top of the measured optimum. */
public void setMargin(int margin) {
this.bufferMargin = margin;
}
public int margin() {
return bufferMargin;
}
/** Ticks currently held ahead of the playback position, i.e. the live buffering depth. */
public int bufferedSpan() {
int total = 0;
for (int i = 0; i < MAX_PACKETS; i++) {
if (data[i] != null && ge(timestamp[i], pointerTimestamp)) total += span[i];
}
return total;
}
/** True when no packet is held and playback has nothing left to drain. */
public boolean isEmpty() {
for (int i = 0; i < MAX_PACKETS; i++) {
if (data[i] != null) return false;
}
return true;
}
/**
* Drops every held packet and forgets both the timeline and the network estimate.
*
* <p>Reserved for the cases where a speaker's position genuinely stopped meaning
* anything — a new speaker, or a silence long enough that the sequence may have been
* recycled. Notably <em>not</em> something to do between talk bursts: the timeline stays
* valid across a pause because the sender stops numbering while the receiver stops
* playing, and the estimate needs far longer than one burst to be worth anything.
*/
public void reset() {
for (int i = 0; i < MAX_PACKETS; i++) data[i] = null;
pointerTimestamp = 0;
nextStop = 0;
resetState = true;
lostCount = 0;
buffered = 0;
interpolationRequested = 0;
autoTradeoff = 32000;
for (TimingWindow w : windows) w.clear();
System.arraycopy(windows, 0, byAge, 0, SUB_WINDOWS);
}
/**
* Offers an arrived packet to the buffer.
*
* @param payload the packet, retained by reference until played or dropped
* @param timestamp the packet's position on the sender's timeline, in ticks
* @param span how many ticks the packet covers
*/
public void put(byte[] payload, int timestamp, int span) {
// Drop anything playback has already moved past.
if (!resetState) {
for (int i = 0; i < MAX_PACKETS; i++) {
if (data[i] != null && le(this.timestamp[i] + this.span[i], pointerTimestamp)) {
data[i] = null;
}
}
}
// A packet that missed its slot still tells us how far behind the clock we are.
boolean late = !resetState && lt(timestamp, nextStop);
if (late) {
updateTimings(timestamp - nextStop - bufferMargin);
}
// The consumer has stopped fetching; don't sit on packets it will never take.
if (lostCount > 20) reset();
// Ignore packets so late that even concealment has moved past them.
if (!resetState && !ge(timestamp + span + delayStep, pointerTimestamp)) return;
int slot = -1;
for (int i = 0; i < MAX_PACKETS; i++) {
if (data[i] == null) {
slot = i;
break;
}
}
if (slot < 0) {
// Full: make room by discarding the oldest packet.
slot = 0;
int earliest = this.timestamp[0];
for (int i = 1; i < MAX_PACKETS; i++) {
if (lt(this.timestamp[i], earliest)) {
earliest = this.timestamp[i];
slot = i;
}
}
}
data[slot] = payload;
this.timestamp[slot] = timestamp;
this.span[slot] = span;
this.arrival[slot] = (resetState || late) ? 0 : nextStop;
}
/**
* Takes the next {@code desiredSpan} ticks of audio.
*
* <p>On {@link Status#OK} the packet is in {@link #payload()}. On {@link Status#MISSING}
* or {@link Status#INSERTION} the caller must produce {@link #span()} ticks of
* concealment instead — for Opus that means decoding a null packet.
*/
public Status get(int desiredSpan) {
resultPayload = null;
// The first packet to arrive defines where the timeline starts.
if (resetState) {
int oldest = 0;
boolean found = false;
for (int i = 0; i < MAX_PACKETS; i++) {
if (data[i] != null && (!found || lt(timestamp[i], oldest))) {
oldest = timestamp[i];
found = true;
}
}
if (!found) {
resultTimestamp = 0;
resultSpan = interpolationRequested;
return Status.MISSING;
}
resetState = false;
pointerTimestamp = oldest;
nextStop = oldest;
}
// A delay increase decided during the last tick(): pad before reading on.
if (interpolationRequested != 0) {
resultTimestamp = pointerTimestamp;
resultSpan = interpolationRequested;
pointerTimestamp += interpolationRequested;
interpolationRequested = 0;
buffered = resultSpan - desiredSpan;
return Status.INSERTION;
}
int found = findPacket(desiredSpan);
if (found >= 0) {
lostCount = 0;
// An on-time arrival: record how much slack it had.
if (arrival[found] != 0) {
updateTimings(timestamp[found] - arrival[found] - bufferMargin);
}
resultPayload = data[found];
resultTimestamp = timestamp[found];
resultSpan = span[found];
data[found] = null;
pointerTimestamp = resultTimestamp + resultSpan;
buffered = resultSpan - desiredSpan;
return Status.OK;
}
lostCount++;
int optimal = computeOptimalDelay();
if (optimal < 0) {
// The measured jitter wants a deeper buffer: insert without advancing playback.
shiftTimings(-optimal);
resultTimestamp = pointerTimestamp;
resultSpan = -optimal;
buffered = resultSpan - desiredSpan;
return Status.INSERTION;
}
// Ordinary loss: conceal a whole number of frames and move on.
int concealed = roundDown(desiredSpan, concealmentSize);
resultTimestamp = pointerTimestamp;
resultSpan = concealed;
pointerTimestamp += concealed;
buffered = concealed - desiredSpan;
return Status.MISSING;
}
/**
* The held packet sitting at the playback position, without consuming it.
*
* <p>After a {@link Status#MISSING} the position has already stepped past the gap, so
* this is the packet immediately following the lost one — the one carrying the FEC copy
* that can rebuild it. {@code null} when that packet is missing too.
*/
public byte[] nextPayload() {
for (int i = 0; i < MAX_PACKETS; i++) {
if (data[i] != null && timestamp[i] == pointerTimestamp) return data[i];
}
return null;
}
/** The timestamp the next {@link #get} will play from. */
public int position() {
return pointerTimestamp;
}
/** Whole frames currently held, given a frame spans {@code frameSpan} ticks. */
public int heldFrames(int frameSpan) {
int held = 0;
for (int i = 0; i < MAX_PACKETS; i++) {
if (data[i] != null) held += Math.max(1, span[i] / frameSpan);
}
return held;
}
/** The packet returned by the last {@link #get}, or {@code null} if it was not {@link Status#OK}. */
public byte[] payload() {
return resultPayload;
}
/** Ticks covered by the last {@link #get}, whether a real packet or concealment. */
public int span() {
return resultSpan;
}
/** Timestamp of the last {@link #get}. */
public int timestamp() {
return resultTimestamp;
}
/**
* Advances the playback clock by one frame, after the audio from {@link #get} has been
* handed to the output. This is also where the delay is re-tuned.
*/
public void tick() {
updateDelay();
nextStop = pointerTimestamp - Math.max(0, buffered);
buffered = 0;
}
/**
* Advances the playback clock by one frame that the caller played from audio it still had
* from an earlier {@link #get}, instead of calling {@link #get} and {@link #tick}.
*
* @param remaining ticks of that audio still left after this frame
*/
public void remainingSpan(int remaining) {
updateDelay();
nextStop = pointerTimestamp - remaining;
}
/** Picks the held packet that best covers the chunk starting at the playback position. */
private int findPacket(int desiredSpan) {
// Exact start, covering the whole chunk.
for (int i = 0; i < MAX_PACKETS; i++) {
if (data[i] != null && timestamp[i] == pointerTimestamp
&& ge(timestamp[i] + span[i], pointerTimestamp + desiredSpan)) {
return i;
}
}
// An earlier packet still covering the whole chunk.
for (int i = 0; i < MAX_PACKETS; i++) {
if (data[i] != null && le(timestamp[i], pointerTimestamp)
&& ge(timestamp[i] + span[i], pointerTimestamp + desiredSpan)) {
return i;
}
}
// An earlier packet covering part of it.
for (int i = 0; i < MAX_PACKETS; i++) {
if (data[i] != null && le(timestamp[i], pointerTimestamp)
&& gt(timestamp[i] + span[i], pointerTimestamp)) {
return i;
}
}
// Anything starting inside the chunk: prefer the earliest, then the longest.
int best = -1;
for (int i = 0; i < MAX_PACKETS; i++) {
if (data[i] == null) continue;
if (!(lt(timestamp[i], pointerTimestamp + desiredSpan) && ge(timestamp[i], pointerTimestamp))) continue;
if (best < 0 || lt(timestamp[i], timestamp[best])
|| (timestamp[i] == timestamp[best] && gt(span[i], span[best]))) {
best = i;
}
}
return best;
}
/**
* Applies the delay the timings call for, by scheduling an insertion to grow the buffer
* or skipping ahead to shrink it. The timings are shifted by the same amount so the
* next estimate is made relative to the new delay.
*/
private void updateDelay() {
int optimal = computeOptimalDelay();
if (optimal == 0) return;
shiftTimings(-optimal);
pointerTimestamp += optimal;
if (optimal < 0) interpolationRequested = -optimal;
}
/**
* Finds the delay minimising {@code delay + lateCost * latePackets} over the observed
* timings, quantised to whole {@link #delayStep}s.
*
* @return ticks to shift playback by: negative to buffer more, positive to catch up
*/
private int computeOptimalDelay() {
int totalSeen = 0;
for (TimingWindow w : windows) totalSeen += w.seen;
if (totalSeen == 0) return 0;
float lateFactor = lateCost != 0
? lateCost * 100.0f / totalSeen
: (float) autoTradeoff * windowSize / totalSeen;
int[] pos = new int[SUB_WINDOWS];
int optimal = 0;
int bestCost = Integer.MAX_VALUE;
int late = 0;
boolean penaltyTaken = false;
int earliestSeen = 0;
int latestSeen = 0;
// Walk the timings from latest to earliest, costing each as a candidate delay.
for (int i = 0; i < TOP_DELAY; i++) {
int next = -1;
int timing = Integer.MAX_VALUE;
for (int j = 0; j < SUB_WINDOWS; j++) {
if (pos[j] < windows[j].filled && windows[j].timing[pos[j]] < timing) {
next = j;
timing = windows[j].timing[pos[j]];
}
}
if (next < 0) break;
if (i == 0) latestSeen = timing;
earliestSeen = timing;
timing = roundDown(timing, delayStep);
pos[next]++;
int cost = (int) (-timing + lateFactor * late);
if (cost < bestCost) {
bestCost = cost;
optimal = timing;
}
late++;
// Hysteresis: charge extra the first time we would start making packets late.
if (timing >= 0 && !penaltyTaken) {
penaltyTaken = true;
late += 4;
}
}
autoTradeoff = 1 + (earliestSeen - latestSeen) / TOP_DELAY;
// Too little evidence to justify shrinking the buffer.
if (totalSeen < TOP_DELAY && optimal > 0) return 0;
return optimal;
}
/** Records one arrival's slack, rotating the sub-windows when the newest fills up. */
private void updateTimings(int timing) {
timing = Math.max(-32767, Math.min(32767, timing));
if (byAge[0].seen >= subWindowSize) {
TimingWindow oldest = byAge[SUB_WINDOWS - 1];
System.arraycopy(byAge, 0, byAge, 1, SUB_WINDOWS - 1);
byAge[0] = oldest;
oldest.clear();
}
byAge[0].add(timing);
}
/** Re-bases every recorded timing after the delay moved. */
private void shiftTimings(int amount) {
for (TimingWindow w : windows) {
for (int i = 0; i < w.filled; i++) w.timing[i] += amount;
}
}
private static int roundDown(int value, int step) {
return value < 0 ? (value - step + 1) / step * step : value / step * step;
}
// Wrap-safe comparisons, so tick counters may overflow.
private static boolean lt(int a, int b) {
return a - b < 0;
}
private static boolean le(int a, int b) {
return a - b <= 0;
}
private static boolean gt(int a, int b) {
return a - b > 0;
}
private static boolean ge(int a, int b) {
return a - b >= 0;
}
}

View File

@@ -0,0 +1,116 @@
package com.ts3client.audio;
import java.util.Arrays;
import java.util.Collection;
import java.util.function.Supplier;
import java.util.function.ToDoubleFunction;
/**
* Mixes every speaker onto one playback line, as mobile audio APIs want: a single
* low-latency stream. The line's blocking writes pace the mix, so it runs on the device's
* clock; between talk bursts the mixer writes nothing and waits.
*/
final class MixedPlayout implements Playout {
private static final int FRAME = VoiceFormat.FRAME_SIZE;
private final AudioIo io;
private final Supplier<String> outputDevice;
private final ToDoubleFunction<VoiceStream> gain;
private final Collection<VoiceStream> streams;
private final Object lock = new Object();
/** Guarded by {@link #lock}. */
private boolean running = true;
private Thread mixer;
MixedPlayout(AudioIo io, Supplier<String> outputDevice, ToDoubleFunction<VoiceStream> gain,
Collection<VoiceStream> streams) {
this.io = io;
this.outputDevice = outputDevice;
this.gain = gain;
this.streams = streams;
}
@Override
public void started(VoiceStream stream) {
synchronized (lock) {
if (!running) return;
if (mixer == null) {
mixer = new Thread(this::mixLoop, "ts3j-voice-mixer");
mixer.setDaemon(true);
mixer.start();
}
lock.notifyAll();
}
}
@Override
public void removed(VoiceStream stream) {
// The stream has already left the collection the mixer iterates.
}
@Override
public void shutdown() {
synchronized (lock) {
running = false;
if (mixer != null) mixer.interrupt();
lock.notifyAll();
}
}
private void mixLoop() {
AudioPlayback line = null;
try {
int channels = 0;
float[] mix = null;
float[] scratch = null;
byte[] out = null;
String device = null;
while (awaitSpeech()) {
if (line != null && !device.equals(outputDevice.get())) {
line.close();
line = null;
}
if (line == null) {
device = outputDevice.get();
line = io.openPlayback(device, VoiceFormat.MAX_CHANNELS);
line.start();
channels = line.channels();
mix = new float[FRAME * channels];
scratch = new float[VoiceStream.MAX_FRAME * channels];
out = new byte[mix.length * 2];
}
Arrays.fill(mix, 0f);
for (VoiceStream stream : streams) {
int frames = stream.pull(scratch, channels, gain.applyAsDouble(stream));
// TeamSpeak clients send 20 ms packets; anything longer is cut to the frame.
int samples = Math.min(frames, FRAME) * channels;
for (int i = 0; i < samples; i++) mix[i] += scratch[i];
}
Pcm16.encode(mix, mix.length, out);
line.write(out, 0, out.length);
}
} catch (Exception ignored) {
// No usable line: this mixer ends, and the next talk burst tries again.
} finally {
if (line != null) line.close();
synchronized (lock) {
mixer = null;
}
}
}
/** Waits until some stream is playing; false once shut down. */
private boolean awaitSpeech() throws InterruptedException {
synchronized (lock) {
while (running) {
for (VoiceStream stream : streams) {
if (stream.isPlaying()) return true;
}
lock.wait();
}
return false;
}
}
}

View File

@@ -1,112 +0,0 @@
package com.ts3client.audio;
import java.util.Arrays;
/**
* Single-channel spectral noise suppressor &mdash; the "Remove background noise"
* (denoise) stage. The TeamSpeak&nbsp;3 client filters steady background noise with
* WebRTC's {@code noise_suppression} module (and a Speex denoiser fallback); this is
* a self-contained equivalent that operates on the STFT bins produced by
* {@link AudioEnhancer}.
*
* <p>The noise floor is tracked per bin by continuous minimum statistics (Doblinger's
* recursive minimum tracker): the estimate follows the valleys of the smoothed power
* spectrum, so modulated speech &mdash; which dips between syllables &mdash; is
* preserved while only near-stationary background energy is learned as noise. From
* that floor a Wiener gain is formed with a decision-directed a&nbsp;priori SNR
* (Ephraim&ndash;Malah smoothing, which keeps musical noise low). A configurable
* aggressiveness ({@code denoiser_level}, 0&ndash;1) sets both the over-subtraction
* factor and the gain floor, i.e. how deeply steady noise is cut.
*/
final class NoiseSuppressor {
/** Power-spectrum smoothing feeding the minimum tracker. */
private static final double POWER_SMOOTH = 0.7;
/** Doblinger minimum-tracker constants. */
private static final double MIN_GAMMA = 0.998;
private static final double MIN_BETA = 0.96;
/** Over-estimation applied to the tracked minimum to get the noise power. */
private static final double NOISE_OVEREST = 1.5;
/** Decision-directed smoothing of the a priori SNR (higher = less musical noise). */
private static final double DD_ALPHA = 0.98;
/** Floor on the a priori SNR (~ -25 dB) to bound the deepest Wiener gain. */
private static final double XI_MIN = 0.003;
private final int bins;
private final double[] smoothed;
private final double[] prevSmoothed;
private final double[] minTrack;
private final double[] priorClean; // previous enhanced power, for the DD estimate
private double overSubtraction = 1.5;
private double gainFloor = dbToGain(-18);
private boolean initialised;
NoiseSuppressor(int bins) {
this.bins = bins;
this.smoothed = new double[bins];
this.prevSmoothed = new double[bins];
this.minTrack = new double[bins];
this.priorClean = new double[bins];
}
/**
* Sets aggressiveness in [0,1]. 0 is a light touch (~6&nbsp;dB max cut), 1 is
* heavy (~30&nbsp;dB) with stronger over-subtraction.
*/
void setLevel(double level) {
double l = Math.max(0, Math.min(1, level));
this.gainFloor = dbToGain(-(6 + 24 * l));
this.overSubtraction = 1.0 + 1.5 * l;
}
void reset() {
initialised = false;
Arrays.fill(smoothed, 0);
Arrays.fill(prevSmoothed, 0);
Arrays.fill(minTrack, 0);
Arrays.fill(priorClean, 0);
}
/** Multiplies the running per-bin gain by this stage's Wiener gain. */
void apply(double[] power, double[] gain) {
if (!initialised) {
for (int k = 0; k < bins; k++) {
smoothed[k] = prevSmoothed[k] = minTrack[k] = power[k];
priorClean[k] = power[k];
}
initialised = true;
}
for (int k = 0; k < bins; k++) {
double p = power[k] + 1e-12;
double s = POWER_SMOOTH * smoothed[k] + (1 - POWER_SMOOTH) * p;
// Doblinger continuous minimum tracking of the smoothed power.
double mt;
if (minTrack[k] < s) {
mt = MIN_GAMMA * minTrack[k]
+ ((1 - MIN_GAMMA) / (1 - MIN_BETA)) * (s - MIN_BETA * smoothed[k]);
} else {
mt = s;
}
minTrack[k] = mt;
smoothed[k] = s;
double noiseK = NOISE_OVEREST * mt + 1e-12;
double gamma = p / (noiseK * overSubtraction); // a posteriori SNR
double xi = DD_ALPHA * (priorClean[k] / noiseK)
+ (1 - DD_ALPHA) * Math.max(gamma - 1, 0); // a priori SNR
if (xi < XI_MIN) xi = XI_MIN;
double g = xi / (1 + xi); // Wiener gain
if (g < gainFloor) g = gainFloor;
priorClean[k] = g * g * p;
gain[k] *= g;
}
}
private static double dbToGain(double db) {
return Math.pow(10.0, db / 20.0);
}
}

View File

@@ -0,0 +1,20 @@
package com.ts3client.audio;
/** Conversion between float samples in [-1, 1] and 16-bit little-endian PCM. */
public final class Pcm16 {
private Pcm16() {
}
/** Writes {@code count} samples to {@code out}, clipping anything out of range. */
public static void encode(float[] samples, int count, byte[] out) {
for (int i = 0; i < count; i++) {
float f = samples[i];
if (f > 1f) f = 1f;
else if (f < -1f) f = -1f;
int s = Math.round(f * 32767f);
out[2 * i] = (byte) (s & 0xFF);
out[2 * i + 1] = (byte) ((s >> 8) & 0xFF);
}
}
}

View File

@@ -0,0 +1,181 @@
package com.ts3client.audio;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import java.util.function.ToDoubleFunction;
/**
* Gives every speaker a playback line and worker thread of its own, so simultaneous
* speakers are mixed by the audio server and one slow decode never blocks another. On a
* PipeWire desktop every speaker then shows up as a separate stream in the mixer.
*/
final class PerSpeakerPlayout implements Playout {
private static final long FRAME_NANOS =
TimeUnit.SECONDS.toNanos(VoiceFormat.FRAME_SIZE) / VoiceFormat.SAMPLE_RATE;
/** Silence after which a speaker's line and thread are given back; the next burst reopens them. */
private static final long IDLE_RELEASE_NANOS = TimeUnit.SECONDS.toNanos(30);
private final class Speaker {
final VoiceStream stream;
final AudioPlayback line;
final int lineChannels;
final ExecutorService worker;
final AtomicBoolean tickQueued = new AtomicBoolean();
/** Touched only by {@link #worker}. */
final float[] pcm;
final byte[] out;
/** Guarded by this speaker. */
ScheduledFuture<?> ticks;
/** Guarded by this speaker; a closed speaker is never ticked again. */
boolean closed;
Speaker(VoiceStream stream) throws Exception {
this.stream = stream;
this.line = io.openPlayback(outputDevice.get(), VoiceFormat.MAX_CHANNELS);
this.lineChannels = line.channels();
this.line.start();
this.pcm = new float[VoiceStream.MAX_FRAME * lineChannels];
this.out = new byte[pcm.length * 2];
this.worker = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "ts3j-play-" + stream.clientId);
t.setDaemon(true);
return t;
});
}
void close() {
synchronized (this) {
closed = true;
if (ticks != null) ticks.cancel(false);
}
worker.shutdownNow();
line.close();
}
}
private final AudioIo io;
private final Supplier<String> outputDevice;
private final ToDoubleFunction<VoiceStream> gain;
private final ScheduledExecutorService clock;
private final Map<VoiceStream, Speaker> speakers = new ConcurrentHashMap<>();
PerSpeakerPlayout(AudioIo io, Supplier<String> outputDevice, ToDoubleFunction<VoiceStream> gain,
ScheduledExecutorService clock) {
this.io = io;
this.outputDevice = outputDevice;
this.gain = gain;
this.clock = clock;
clock.scheduleWithFixedDelay(this::releaseIdle, 1, 1, TimeUnit.SECONDS);
}
@Override
public void started(VoiceStream stream) {
while (true) {
Speaker speaker = speakers.get(stream);
if (speaker == null) {
try {
speaker = new Speaker(stream);
} catch (Exception e) {
stream.stop(); // couldn't open a line; drop the burst
return;
}
Speaker existing = speakers.putIfAbsent(stream, speaker);
if (existing != null) {
speaker.close();
speaker = existing;
}
}
synchronized (speaker) {
if (speaker.closed) {
// Released a moment ago; this burst gets a fresh line.
speakers.remove(stream, speaker);
continue;
}
if (speaker.ticks == null) {
Speaker s = speaker;
speaker.ticks = clock.scheduleAtFixedRate(
() -> queueTick(s), FRAME_NANOS, FRAME_NANOS, TimeUnit.NANOSECONDS);
}
return;
}
}
}
/** Gives back the line and thread of every speaker that has been silent for a while. */
private void releaseIdle() {
long now = System.nanoTime();
for (Speaker speaker : speakers.values()) {
synchronized (speaker) {
if (speaker.ticks != null || now - speaker.stream.lastPacketNanos() < IDLE_RELEASE_NANOS) continue;
speaker.closed = true;
}
speakers.remove(speaker.stream, speaker);
speaker.close();
}
}
private void queueTick(Speaker speaker) {
if (!speaker.tickQueued.compareAndSet(false, true)) return;
try {
speaker.worker.submit(() -> {
try {
tick(speaker);
} finally {
speaker.tickQueued.set(false);
}
});
} catch (RuntimeException e) {
if (speaker.worker.isShutdown()) {
speaker.tickQueued.set(false);
} else {
throw e;
}
}
}
private void tick(Speaker speaker) {
int frames = speaker.stream.pull(speaker.pcm, speaker.lineChannels, gain.applyAsDouble(speaker.stream));
if (frames == VoiceStream.IDLE) {
synchronized (speaker) {
// A packet may have started the next burst since the pull; keep ticking then.
if (!speaker.stream.isPlaying() && speaker.ticks != null) {
speaker.ticks.cancel(false);
speaker.ticks = null;
}
}
return;
}
if (frames == 0) return;
int samples = frames * speaker.lineChannels;
Pcm16.encode(speaker.pcm, samples, speaker.out);
try {
speaker.line.write(speaker.out, 0, samples * 2);
} catch (RuntimeException e) {
// The line died (a device went away, say). Drop it rather than keep writing into it:
// the speaker's next packet starts a burst on a fresh one.
speakers.remove(speaker.stream, speaker);
speaker.close();
speaker.stream.stop();
}
}
@Override
public void removed(VoiceStream stream) {
Speaker speaker = speakers.remove(stream);
if (speaker != null) speaker.close();
}
@Override
public void shutdown() {
for (Speaker speaker : speakers.values()) speaker.close();
speakers.clear();
}
}

View File

@@ -0,0 +1,13 @@
package com.ts3client.audio;
/** Drives the {@link VoiceStream}s of a {@link StreamingVoiceOutput} out to the speakers. */
interface Playout {
/** A stream began a talk burst and must now be pulled every 20 ms. */
void started(VoiceStream stream);
/** The stream's client left; it will not be pulled again. */
void removed(VoiceStream stream);
void shutdown();
}

View File

@@ -1,112 +0,0 @@
package com.ts3client.audio;
/**
* Lightweight speech-presence detector used by the "Automatic" and "Hybrid" voice
* activation modes (the TeamSpeak 3 client uses a WebRTC GMM detector for the same
* purpose). It classifies each 20&nbsp;ms frame from three features — short-term
* energy, spectral flatness and dominant frequency — against an adaptive noise
* floor, following Moattar &amp; Homayounpour's real-time VAD.
*
* <p>Pure DSP with no platform dependencies, so any frontend/backend can reuse it.
* A frame votes for speech when at least two of the three features exceed the
* baseline; the smoothed vote fraction is exposed as a [0,1] probability.
*/
public final class SpeechDetector {
private static final int FFT_SIZE = 1024;
// Primary thresholds from the reference algorithm (16-bit sample scale).
private static final double ENERGY_PRIM = 40.0;
private static final double DOMINANT_FREQ_PRIM = 185.0;
private static final double FLATNESS_PRIM = 5.0;
private final int sampleRate;
private final double[] re = new double[FFT_SIZE];
private final double[] im = new double[FFT_SIZE];
private double minEnergy;
private double minDominantFreq;
private double minFlatness;
private long silenceFrames;
private long frameCount;
private double probability;
public SpeechDetector(int sampleRate) {
this.sampleRate = sampleRate;
}
public void reset() {
frameCount = 0;
silenceFrames = 0;
probability = 0;
}
public double getProbability() {
return probability;
}
/**
* Processes one frame of mono PCM in [-1,1] and returns the smoothed speech
* probability in [0,1].
*/
public double process(float[] frame) {
double energy = 0;
int n = Math.min(frame.length, FFT_SIZE);
for (int i = 0; i < n; i++) {
double s = frame[i] * 32768.0; // emulate 16-bit scale
energy += s * s;
re[i] = frame[i];
im[i] = 0;
}
for (int i = n; i < FFT_SIZE; i++) {
re[i] = 0;
im[i] = 0;
}
Fft.forward(re, im);
int half = FFT_SIZE / 2;
double geoLogSum = 0;
double arithSum = 0;
double maxMag = 0;
int maxBin = 0;
for (int k = 1; k < half; k++) {
double mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]) + 1e-12;
geoLogSum += Math.log(mag);
arithSum += mag;
if (mag > maxMag) {
maxMag = mag;
maxBin = k;
}
}
int bins = half - 1;
double geoMean = Math.exp(geoLogSum / bins);
double arithMean = arithSum / bins;
double flatness = -10.0 * Math.log10(geoMean / arithMean); // high = tonal (speech)
double dominantFreq = (double) maxBin * sampleRate / FFT_SIZE;
if (frameCount == 0) {
minEnergy = Math.max(energy, 1.0);
minDominantFreq = dominantFreq;
minFlatness = flatness;
}
double energyThresh = ENERGY_PRIM * Math.log10(Math.max(minEnergy, 1.0));
int votes = 0;
if (energy - minEnergy >= energyThresh) votes++;
if (dominantFreq - minDominantFreq >= DOMINANT_FREQ_PRIM) votes++;
if (flatness - minFlatness >= FLATNESS_PRIM) votes++;
boolean speech = votes >= 2;
if (!speech) {
// Adapt the noise floor towards the current (silent) energy.
silenceFrames++;
minEnergy = ((silenceFrames * minEnergy) + energy) / (silenceFrames + 1);
}
double instant = votes / 3.0;
probability = 0.6 * probability + 0.4 * instant;
frameCount++;
return probability;
}
}

View File

@@ -0,0 +1,27 @@
package com.ts3client.audio;
/**
* Estimates how likely it is that a frame of captured audio contains speech.
*
* <p>Used by the "Automatic" and "Hybrid" voice-activation modes. Implementations are
* stateful and expect frames in capture order; {@link #reset()} clears that state.
*
* <p>Pure DSP with no platform dependencies, so any frontend or backend can reuse it.
*/
public interface SpeechProbabilityDetector {
/**
* Consumes one frame of mono PCM in [-1, 1] and returns the current speech
* probability in [0, 1].
*
* @param frame samples in capture order
* @param length number of valid samples in {@code frame}
*/
double process(float[] frame, int length);
/** Speech probability from the most recent {@link #process} call. */
double probability();
/** Drops all history. Call on a capture discontinuity or a settings change. */
void reset();
}

View File

@@ -0,0 +1,182 @@
package com.ts3client.audio;
import com.github.manevolent.ts3j.enums.CodecType;
import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice;
import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper;
import com.ts3client.audio.opus.OpusCodec;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
/**
* The {@link VoiceOutput} every platform shares: decodes incoming voice per speaker, each
* through its own jitter buffer, and plays it on the platform's {@link AudioIo}.
*/
public final class StreamingVoiceOutput implements VoiceOutput {
/** How speakers reach the device. */
public enum Lines {
/** A line per speaker, mixed by the platform's audio server. */
PER_SPEAKER,
/** One line all speakers are mixed onto here. */
SHARED
}
/**
* A speaker is only meant to stop when the empty voice packet marking the end of a
* talk burst arrives — but that packet is UDP too, and a lost one otherwise leaves
* the talking indicator stuck until the speaker's next burst. Opus frames are 20&nbsp;ms
* apart while someone is actually talking, so a gap several times that long, with no
* packet of either kind, is unambiguous: force the indicator off rather than trust
* the one packet that could go missing.
*/
private static final long TALK_TIMEOUT_NANOS = TimeUnit.MILLISECONDS.toNanos(400);
private final OpusCodec opus;
private final Map<Integer, VoiceStream> streams = new ConcurrentHashMap<>();
private final Set<Integer> mutedClients = ConcurrentHashMap.newKeySet();
/** Per-client gain on top of the master volume; absent means 1.0. */
private final Map<Integer, Double> clientVolumes = new ConcurrentHashMap<>();
private volatile String outputDevice;
private volatile double masterVolume = 1.0;
private volatile boolean deafened = false;
/** Notified (clientId, talking) on an audio thread when a speaker starts/stops. */
private volatile BiConsumer<Integer, Boolean> talkListener;
/** Catches talk bursts whose end packet never arrived, and paces per-speaker lines. */
private final ScheduledExecutorService clock = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "ts3j-talk-watchdog");
t.setDaemon(true);
return t;
});
private final Playout playout;
public StreamingVoiceOutput(AudioIo io, OpusCodec opus, String outputDevice, Lines lines) {
this.opus = opus;
this.outputDevice = outputDevice == null ? "" : outputDevice;
this.playout = switch (lines) {
case PER_SPEAKER -> new PerSpeakerPlayout(io, () -> this.outputDevice, this::gainFor, clock);
case SHARED -> new MixedPlayout(io, () -> this.outputDevice, this::gainFor, streams.values());
};
clock.scheduleWithFixedDelay(this::checkTalkTimeouts, 50, 50, TimeUnit.MILLISECONDS);
}
private void checkTalkTimeouts() {
long now = System.nanoTime();
for (VoiceStream s : streams.values()) {
if (s.isTalking() && now - s.lastPacketNanos() > TALK_TIMEOUT_NANOS) s.stop();
}
}
private double gainFor(VoiceStream stream) {
return masterVolume * clientVolumes.getOrDefault(stream.clientId, 1.0);
}
private void fireTalk(int clientId, boolean talking) {
BiConsumer<Integer, Boolean> l = talkListener;
if (l != null) l.accept(clientId, talking);
}
@Override
public void setTalkListener(BiConsumer<Integer, Boolean> l) {
this.talkListener = l;
}
@Override
public void setMasterVolume(double v) {
this.masterVolume = Math.max(0, Math.min(2.0, v));
}
@Override
public void setDeafened(boolean d) {
this.deafened = d;
if (d) {
// Stop everyone talking immediately.
for (VoiceStream s : streams.values()) s.stop();
}
}
@Override
public boolean isDeafened() {
return deafened;
}
@Override
public void setClientMuted(int clientId, boolean muted) {
if (muted) {
mutedClients.add(clientId);
VoiceStream s = streams.get(clientId);
if (s != null) s.stop();
} else {
mutedClients.remove(clientId);
}
}
@Override
public boolean isClientMuted(int clientId) {
return mutedClients.contains(clientId);
}
@Override
public void setClientVolume(int clientId, double gain) {
if (gain == 1.0) clientVolumes.remove(clientId);
else clientVolumes.put(clientId, Math.max(0, gain));
}
@Override
public double getClientVolume(int clientId) {
return clientVolumes.getOrDefault(clientId, 1.0);
}
@Override
public void setOutputDevice(String device) {
this.outputDevice = device == null ? "" : device;
}
/** Entry point wired into {@code client.setVoiceHandler(...)}. */
@Override
public void handleVoice(PacketBody0Voice voice) {
route(voice.getClientId(), voice.getPacketId(), voice.getCodecType(), voice.getCodecData());
}
/** Entry point wired into {@code client.setWhisperHandler(...)}. */
@Override
public void handleWhisper(PacketBody1VoiceWhisper whisper) {
route(whisper.getClientId(), whisper.getPacketId(), whisper.getCodecType(), whisper.getCodecData());
}
private void route(int clientId, int packetId, CodecType codec, byte[] data) {
if (deafened) return;
if (mutedClients.contains(clientId)) return;
VoiceStream stream = streams.computeIfAbsent(clientId, id -> new VoiceStream(id, opus, this::fireTalk));
if (stream.offer(packetId, codec, data)) playout.started(stream);
}
/** Forgets a client that left; the server may hand its id to somebody else. */
@Override
public void removeClient(int clientId) {
VoiceStream s = streams.remove(clientId);
if (s != null) {
playout.removed(s);
s.close();
}
mutedClients.remove(clientId);
clientVolumes.remove(clientId);
}
@Override
public void shutdown() {
clock.shutdownNow();
playout.shutdown();
for (VoiceStream s : streams.values()) s.close();
streams.clear();
}
}

View File

@@ -1,78 +0,0 @@
package com.ts3client.audio;
/**
* Transient (keystroke) suppressor &mdash; the "Typing attenuation" stage, which per
* the TeamSpeak&nbsp;3 client "tries to detect and reduce the sounds made by typing"
* (WebRTC's {@code transient_suppression} module). Key clicks are short, impulsive,
* broadband bursts with a strong high-frequency component, unlike voiced speech which
* is sustained and low-frequency dominant.
*
* <p>Each STFT block is scored for a keystroke signature: a sudden jump in total
* power (both against the previous block and a slow running floor) together with an
* elevated high-frequency energy ratio. Matching blocks are ducked broadband with an
* immediate attack and a short release. A hold cap ensures only genuinely brief
* events are cut &mdash; a sustained sound such as a fricative outlasts the cap and is
* released, so speech is preserved.
*/
final class TypingAttenuator {
private static final double HF_HZ = 4000.0; // high-frequency band start
private static final double ONSET_FACTOR = 2.5; // total power vs slow floor
private static final double FLUX_FACTOR = 3.0; // total power vs previous block
private static final double HF_RATIO = 0.30; // fraction of energy above HF_HZ
private static final double SUPPRESS = 0.12; // ducking gain on a detected click (~ -18 dB)
private static final double RELEASE = 0.25; // recovery fraction per block after a click
private static final double FLOOR_SMOOTH = 0.98; // slow power-floor tracking
private static final int MAX_HOLD = 4; // max consecutive ducked blocks (~clicks only)
private final int bins;
private final int hfBin;
private double slowPower;
private double prevPower;
private double envGain = 1.0;
private int heldBlocks;
TypingAttenuator(int bins, int sampleRate, int fftSize) {
this.bins = bins;
this.hfBin = (int) Math.round(HF_HZ * fftSize / sampleRate);
}
void reset() {
slowPower = 0;
prevPower = 0;
envGain = 1.0;
heldBlocks = 0;
}
/** Multiplies the running per-bin gain by the current broadband ducking gain. */
void apply(double[] power, double[] gain) {
double total = 0, high = 0;
for (int k = 0; k < bins; k++) {
total += power[k];
if (k >= hfBin) high += power[k];
}
double hfRatio = high / (total + 1e-12);
boolean signature = total > slowPower * ONSET_FACTOR
&& total > prevPower * FLUX_FACTOR
&& hfRatio > HF_RATIO;
if (signature && heldBlocks < MAX_HOLD) {
envGain = SUPPRESS; // fast attack: duck immediately
heldBlocks++;
} else {
envGain += (1 - envGain) * RELEASE;
if (!signature) {
heldBlocks = 0;
// Only let the floor track when we're not inside a transient.
slowPower = slowPower == 0 ? total : FLOOR_SMOOTH * slowPower + (1 - FLOOR_SMOOTH) * total;
}
}
prevPower = total;
for (int k = 0; k < bins; k++) {
gain[k] *= envGain;
}
}
}

View File

@@ -0,0 +1,16 @@
package com.ts3client.audio;
/**
* The PCM format voice is captured, processed and played in. TeamSpeak's Opus codecs run at
* 48&nbsp;kHz in 20&nbsp;ms frames, mono for {@code OPUS_VOICE} and stereo for {@code OPUS_MUSIC}.
*/
public final class VoiceFormat {
public static final int SAMPLE_RATE = 48_000;
/** Samples per channel in one 20 ms frame. */
public static final int FRAME_SIZE = 960;
public static final int MAX_CHANNELS = 2;
private VoiceFormat() {
}
}

View File

@@ -18,11 +18,23 @@ public interface VoiceInput extends Microphone {
void setMuted(boolean muted); void setMuted(boolean muted);
/**
* Silences capture the same way {@link #setMuted} does, but locally only: it does
* not touch {@link #isMuted}, so the mute status published to the server (and the
* mute/unmute sound) is unaffected. TS3's "Local Mic Mute".
*/
void setLocalMuted(boolean muted);
boolean isLocalMuted();
/** Moves capture to another device, reopening at once if capturing; empty is the default. */
void setInputDevice(String deviceId);
void setMode(Settings.InputMode mode); void setMode(Settings.InputMode mode);
void setVadMode(Settings.VadMode mode); void setVadMode(Settings.VadMode mode);
/** Voice-activation volume-gate threshold in dBFS. */ /** Volume-gate threshold on {@link InputLevel}'s scale. */
void setThresholdDb(double db); void setThresholdDb(double db);
/** Speech-probability threshold (0..1) for Automatic/Hybrid modes. */ /** Speech-probability threshold (0..1) for Automatic/Hybrid modes. */
@@ -33,29 +45,59 @@ public interface VoiceInput extends Microphone {
void setInputGain(double gain); void setInputGain(double gain);
/** Enables removal of steady background noise (spectral denoise). */ /** Enables removal of steady background noise (WebRTC noise suppression). */
void setNoiseSuppression(boolean enabled); void setNoiseSuppression(boolean enabled);
/** Background-noise removal aggressiveness, 0 (light) .. 1 (heavy). */ /** Noise suppression strength as TS3's {@code denoiser_level}: 0 (6 dB) .. 3 (21 dB). */
void setDenoiserLevel(double level); void setDenoiserLevel(int level);
/** Enables detection and attenuation of keyboard typing sounds. */ /** Enables attenuation of keyboard clicks while the user is typing. */
void setTypingAttenuation(boolean enabled); void setTypingAttenuation(boolean enabled);
/** Reports a key press anywhere on the system; typing attenuation only acts while typing. */
void keyPressed();
/** Enables automatic gain control (normalise microphone loudness). */ /** Enables automatic gain control (normalise microphone loudness). */
void setAgc(boolean enabled); void setAgc(boolean enabled);
/** Lets AGC raise the background noise this many dB more to bring up a quiet voice. */
void setAgcBoost(int db);
/** Applies Opus encoder parameters, taking effect immediately if capturing. */ /** Applies Opus encoder parameters, taking effect immediately if capturing. */
void setOpusParameters(OpusParameters parameters); void setOpusParameters(OpusParameters parameters);
void setPushToTalk(boolean pressed); void setPushToTalk(boolean pressed);
/** Receives the live input level in dBFS, once per captured frame. */ /** Receives the live input level on {@link InputLevel}'s scale, once per captured frame. */
void setLevelListener(Consumer<Double> listener); void setLevelListener(Consumer<Double> listener);
/** Receives local transmit-state transitions (talking / silent). */ /** Receives local transmit-state transitions (talking / silent). */
void setTalkListener(Consumer<Boolean> listener); void setTalkListener(Consumer<Boolean> listener);
/**
* Receives every frame that is actually transmitted, for local monitoring: the audio
* after pre-processing and gating, including the pre-roll replayed when the gate opens.
* Nothing is delivered while the gate is shut or the microphone is muted.
*/
void setMonitorListener(AudioFrameListener listener);
/** Called when speech is detected while the microphone is muted. */ /** Called when speech is detected while the microphone is muted. */
void setMutedTalkListener(Runnable listener); void setMutedTalkListener(Runnable listener);
/** Takes on the voice settings, as they stand now, while capturing. */
default void apply(Settings settings) {
setInputDevice(settings.inputDevice);
setMode(settings.inputMode);
setVadMode(settings.vadMode);
setThresholdDb(settings.vadThresholdDb);
setSpeechThreshold(settings.speechThreshold);
setVadOverPtt(settings.vadOverPtt);
setInputGain(settings.inputVolume);
setNoiseSuppression(settings.denoise);
setDenoiserLevel(settings.denoiserLevel);
setTypingAttenuation(settings.typingAttenuation);
setAgc(settings.agc);
setAgcBoost(settings.agcBoostDb);
setOpusParameters(OpusParameters.from(settings));
}
} }

View File

@@ -27,6 +27,11 @@ public interface VoiceOutput {
boolean isClientMuted(int clientId); boolean isClientMuted(int clientId);
/** Scales one client's voice on top of the master volume; 1.0 is unchanged, the default. */
void setClientVolume(int clientId, double gain);
double getClientVolume(int clientId);
void setOutputDevice(String device); void setOutputDevice(String device);
void removeClient(int clientId); void removeClient(int clientId);

View File

@@ -0,0 +1,379 @@
package com.ts3client.audio;
import com.github.manevolent.ts3j.enums.CodecType;
import com.ts3client.audio.opus.OpusCodec;
import com.ts3client.audio.opus.OpusDecoder;
import java.util.function.BiConsumer;
/**
* One speaker's incoming voice: packets go into an adaptive {@link JitterBuffer} as they
* arrive, and every {@link #pull} plays out the next 20&nbsp;ms, concealing what went missing.
*
* <p>The buffer is driven the way the official client drives it. A speaker has one timeline,
* {@code unwrap16(packetId) * 60} ticks, which is never re-anchored between talk bursts: the
* sender stops numbering when it stops talking and playback stops at the same point, so the
* next burst lands where the last one left off and the delay the buffer has learned carries
* over. Only a long silence drops the timeline.
*
* <p>An empty packet is the talker letting go of the key. It never enters the buffer; its
* position is remembered, the burst ends once playback reaches it, and its slot is played as
* silence rather than concealed, since nothing was lost there.
*
* <p>The stream keeps no clock of its own: whoever pulls sets the pace, one call per
* 20&nbsp;ms frame. {@link #offer} and {@link #stop} may be called from any thread; pulls
* must come from one thread at a time.
*/
final class VoiceStream {
/** Returned by {@link #pull} when no talk burst is playing. */
static final int IDLE = -1;
/** Longest Opus frame (120 ms @ 48 kHz) a packet may decode to, per channel. */
static final int MAX_FRAME = 5760;
private static final int FRAME = VoiceFormat.FRAME_SIZE;
private static final int FRAME_TICKS = JitterBuffer.TEAMSPEAK_TICKS_PER_FRAME;
/** Pulls in a row with nothing held before a burst whose end marker got lost is given up. */
private static final int EMPTY_PULLS_TO_END = 10;
/** How far back a talker's stop is remembered, as in the official client. */
private static final int STOP_MARK_WINDOW_TICKS = 3000;
/** Silence after which the timeline and the delay estimate are dropped, as in the official client. */
private static final long IDLE_RESET_NANOS = 5_000_000_000L;
private static final int EPOCH_UNSET = Integer.MIN_VALUE;
/** What a pull plays, decided under the lock and produced outside it. */
private enum Source { PACKET, CONCEAL, SILENCE, SURPLUS }
final int clientId;
private final OpusCodec opus;
private final BiConsumer<Integer, Boolean> talkListener;
private final Object lock = new Object();
// Guarded by lock.
private final JitterBuffer jitter = JitterBuffer.forTeamSpeakVoice(FRAME_TICKS);
private int epoch = EPOCH_UNSET;
private int lastSeq;
private int[] stopMarks = new int[8];
private int stopMarkCount;
/** Channels of the latest packet: TeamSpeak streams {@code OPUS_MUSIC} in stereo. */
private int channels = 1;
private boolean playing;
private int emptyPulls;
/** Frames of decoded audio left over from the last packet, still to be played. */
private int surplusFrames;
/** Frames of concealment still owed to a delay increase. */
private int concealOwed;
/** Set when the timeline was dropped; the decoder follows on the next pull. */
private boolean resetDecoder;
/** Guards the decoder: a close from another thread may reach it mid-pull. */
private final Object decoderLock = new Object();
private final float[] pcm = new float[MAX_FRAME * VoiceFormat.MAX_CHANNELS];
/** Audio a packet decoded to beyond its first frame; only the pulling thread touches it. */
private final float[] surplus = new float[MAX_FRAME * VoiceFormat.MAX_CHANNELS];
private int surplusOffset;
private int surplusChannels;
private OpusDecoder decoder;
private int decoderChannels;
private boolean closed;
private volatile boolean talking;
private volatile long lastPacketNanos;
VoiceStream(int clientId, OpusCodec opus, BiConsumer<Integer, Boolean> talkListener) {
this.clientId = clientId;
this.opus = opus;
this.talkListener = talkListener;
}
private static int channelsFor(CodecType codec) {
return codec == CodecType.OPUS_MUSIC ? 2 : 1;
}
/**
* Places an incoming packet on the speaker's timeline; an empty one marks the end of the
* talk burst.
*
* @return true if the packet started a new burst, so playout has to begin
*/
boolean offer(int packetId, CodecType codec, byte[] data) {
long now = System.nanoTime();
boolean longIdle = lastPacketNanos != 0 && now - lastPacketNanos >= IDLE_RESET_NANOS;
lastPacketNanos = now;
boolean end = data == null || data.length == 0;
boolean started = false;
synchronized (lock) {
if (longIdle && !playing) {
jitter.reset();
epoch = EPOCH_UNSET;
stopMarkCount = 0;
resetDecoder = true;
}
boolean fresh = epoch == EPOCH_UNSET;
int ticks = unwrap(packetId & 0xFFFF) * FRAME_TICKS;
if (end) {
markStop(ticks);
return false;
}
// Playback already went past it; the buffer would only drop it.
if (!fresh && ticks - jitter.position() < 0) return false;
channels = channelsFor(codec);
jitter.put(data, ticks, FRAME_TICKS);
if (!playing) {
playing = true;
started = true;
emptyPulls = 0;
}
}
markTalking(true);
return started;
}
/**
* Plays out the next frame into {@code out}, interleaved over {@code outChannels} and
* scaled by {@code gain} but not clipped, so it can still be mixed.
*
* @param out room for {@link VoiceFormat#FRAME_SIZE} samples per channel
* @return the samples written per channel, at most one frame; 0 if a packet could not be
* decoded, or {@link #IDLE} once the burst is over
*/
int pull(float[] out, int outChannels, double gain) {
Source source;
byte[] packet = null;
int surplusTake = 0;
int packetChannels;
boolean reset;
synchronized (lock) {
if (!playing) return IDLE;
reset = resetDecoder;
resetDecoder = false;
packetChannels = channels;
if (surplusFrames > 0 || concealOwed > 0) {
// Still playing out what the last get() handed over.
if (surplusFrames > 0) {
source = Source.SURPLUS;
surplusTake = Math.min(FRAME, surplusFrames);
surplusFrames -= surplusTake;
} else {
source = Source.CONCEAL;
concealOwed--;
}
jitter.remainingSpan(remainingTicks());
} else {
boolean empty = jitter.isEmpty();
emptyPulls = empty ? emptyPulls + 1 : 0;
if (empty && (stoppedAt(jitter.position()) || emptyPulls >= EMPTY_PULLS_TO_END)) {
stopLocked();
source = null;
} else {
JitterBuffer.Status status = jitter.get(FRAME_TICKS);
switch (status) {
case OK -> {
source = Source.PACKET;
packet = jitter.payload();
}
case MISSING -> source = isStopMark(jitter.timestamp()) ? Source.SILENCE : Source.CONCEAL;
default -> {
// The buffer grows its delay by inserting this many frames.
source = Source.CONCEAL;
concealOwed = Math.max(1, jitter.span() / FRAME_TICKS) - 1;
}
}
jitter.tick();
}
}
}
if (source == null) {
markTalking(false);
return IDLE;
}
int frames;
synchronized (decoderLock) {
if (closed) return 0;
try {
if (reset && decoder != null) decoder.reset();
frames = produce(source, packet, surplusTake, packetChannels, out, outChannels, gain);
} catch (RuntimeException e) {
return 0;
}
}
return frames;
}
private int produce(Source source, byte[] packet, int surplusTake, int packetChannels, float[] out,
int outChannels, double gain) {
switch (source) {
case SURPLUS -> {
emit(surplus, surplusOffset, surplusTake, surplusChannels, out, outChannels, gain);
surplusOffset += surplusTake;
return surplusTake;
}
case SILENCE -> {
java.util.Arrays.fill(out, 0, FRAME * outChannels, 0f);
return FRAME;
}
case CONCEAL -> {
int frames = decoderFor(decoderChannels == 0 ? packetChannels : decoderChannels).conceal(pcm, FRAME);
emit(pcm, 0, frames, decoderChannels, out, outChannels, gain);
return frames;
}
default -> {
int frames = decoderFor(packetChannels).decode(packet, pcm);
int played = Math.min(frames, FRAME);
emit(pcm, 0, played, packetChannels, out, outChannels, gain);
if (frames > played) keepSurplus(played, frames - played, packetChannels);
return played;
}
}
}
/** Holds back what a packet decoded to beyond one frame, for the following pulls. */
private void keepSurplus(int from, int frames, int channels) {
System.arraycopy(pcm, from * channels, surplus, 0, frames * channels);
surplusOffset = 0;
surplusChannels = channels;
synchronized (lock) {
if (playing) surplusFrames = frames;
}
}
/** Ticks of audio already handed out but not yet played; guarded by the lock. */
private int remainingTicks() {
return (surplusFrames * FRAME_TICKS + FRAME - 1) / FRAME + concealOwed * FRAME_TICKS;
}
/**
* Matches decoded audio to the output: duplicates mono across stereo and folds a stereo
* (music) stream down onto mono.
*/
private static void emit(float[] src, int fromFrame, int frames, int channels, float[] out, int outChannels,
double gain) {
int base = fromFrame * channels;
for (int i = 0, k = 0; i < frames; i++) {
for (int c = 0; c < outChannels; c++, k++) {
double v;
if (channels == outChannels) {
v = src[base + i * channels + c];
} else if (channels == 1) {
v = src[base + i];
} else {
double sum = 0;
for (int s = 0; s < channels; s++) sum += src[base + i * channels + s];
v = sum / channels;
}
out[k] = (float) (v * gain);
}
}
}
/**
* Returns a decoder matching the stream's channel count, rebuilding it when a speaker
* switches between the mono {@code OPUS_VOICE} and stereo {@code OPUS_MUSIC} codecs.
*/
private OpusDecoder decoderFor(int channels) {
if (decoder == null || decoderChannels != channels) {
if (decoder != null) decoder.close();
decoder = opus.createDecoder(VoiceFormat.SAMPLE_RATE, MAX_FRAME, channels);
decoderChannels = channels;
}
return decoder;
}
/**
* Widens the 16-bit packet id into a continuous sequence. A packet that arrives after the
* counter wrapped, but was sent before it, counts back into the previous epoch.
*/
private int unwrap(int seq) {
if (epoch == EPOCH_UNSET) {
epoch = 0;
lastSeq = seq;
} else {
int delta = (short) (seq - lastSeq);
if (delta > 0) {
if (seq < lastSeq) epoch++;
lastSeq = seq;
} else if (seq > lastSeq) {
epoch--;
}
}
return (epoch << 16) | seq;
}
private void markStop(int ticks) {
int position = jitter.position();
int kept = 0;
for (int i = 0; i < stopMarkCount; i++) {
if (position - stopMarks[i] < STOP_MARK_WINDOW_TICKS) stopMarks[kept++] = stopMarks[i];
}
stopMarkCount = kept;
if (stopMarkCount == stopMarks.length) stopMarks = java.util.Arrays.copyOf(stopMarks, stopMarkCount * 2);
stopMarks[stopMarkCount++] = ticks;
}
private boolean isStopMark(int ticks) {
for (int i = 0; i < stopMarkCount; i++) {
if (stopMarks[i] == ticks) return true;
}
return false;
}
/** Whether the talker let go at or shortly before {@code position}, so the burst is spoken out. */
private boolean stoppedAt(int position) {
for (int i = 0; i < stopMarkCount; i++) {
int behind = position - stopMarks[i];
if (behind >= 0 && behind < STOP_MARK_WINDOW_TICKS) return true;
}
return false;
}
/** Ends the current talk burst, if any; the next packet starts a new one. */
void stop() {
synchronized (lock) {
stopLocked();
}
markTalking(false);
}
private void stopLocked() {
playing = false;
surplusFrames = 0;
concealOwed = 0;
emptyPulls = 0;
}
boolean isPlaying() {
synchronized (lock) {
return playing;
}
}
boolean isTalking() {
return talking;
}
long lastPacketNanos() {
return lastPacketNanos;
}
void close() {
synchronized (lock) {
stopLocked();
}
synchronized (decoderLock) {
closed = true;
if (decoder != null) decoder.close();
decoder = null;
}
}
private void markTalking(boolean talking) {
if (this.talking == talking) return;
this.talking = talking;
talkListener.accept(clientId, talking);
}
}

View File

@@ -0,0 +1,38 @@
package com.ts3client.audio.opus;
/** An {@link OpusEncoder} configured through libopus' own controls; a binding supplies {@link #ctl}. */
public abstract class CtlOpusEncoder implements OpusEncoder {
/** Calls {@code opus_encoder_ctl(encoder, request, value)}. */
protected abstract void ctl(int request, int value);
@Override
public void setBitrate(int bitsPerSecond) {
ctl(LibOpus.SET_BITRATE_REQUEST, bitsPerSecond);
}
@Override
public void setComplexity(int complexity) {
ctl(LibOpus.SET_COMPLEXITY_REQUEST, Math.max(0, Math.min(10, complexity)));
}
@Override
public void setVbr(boolean vbr) {
ctl(LibOpus.SET_VBR_REQUEST, vbr ? 1 : 0);
}
@Override
public void setInbandFec(boolean fec) {
ctl(LibOpus.SET_INBAND_FEC_REQUEST, fec ? 1 : 0);
}
@Override
public void setExpectedPacketLoss(int percent) {
ctl(LibOpus.SET_PACKET_LOSS_PERC_REQUEST, Math.max(0, Math.min(100, percent)));
}
@Override
public void setSignal(Signal signal) {
ctl(LibOpus.SET_SIGNAL_REQUEST, signal == Signal.MUSIC ? LibOpus.SIGNAL_MUSIC : LibOpus.SIGNAL_VOICE);
}
}

Some files were not shown because too many files have changed in this diff Show More