Compare commits

...

45 Commits

Author SHA1 Message Date
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
97148cfe9a Move a client into your own channel from its context menu
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:30:12 +02:00
6dee5106c7 Open text chat by double-clicking a client
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 14:48:54 +00:00
9bb1ec7060 Open connection info by middle-clicking a client
The dialog now disposes when its window button is used, too: hiding it
left the one-second refresh timer polling the server for a window
nobody could see again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:39:19 +00:00
69003ced9a Close the client's dialogs with Escape
Only the option-pane prompts closed on Escape, because Swing installs
that binding on the option pane itself. Dialogs.closeOnEscape puts the
same window-wide binding on a dialog's root pane, routed to whatever
that dialog does when its Close or Cancel button is pressed — reverting
the live-previewing settings, or flushing the bookmark being edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:36:17 +00:00
0f8de796ea Kick and ban clients from the context menu
The client menu gains "Kick Client from Channel", "Kick Client from
Server" and "Ban Client", each asking for a reason first. Kicks use a
shared prompt that enforces the protocol's 40-character limit; the ban
dialog adds a duration, whose unit dropdown ends in "Permanent" — the
zero-length ban the server understands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:29:21 +00:00
d37c6c9ba9 Name the server node and stop it collapsing
The tree's top row showed the placeholder name, since nothing ever read the
real one; take it from initserver, which ts3j now keeps.

There is only ever one top-level row, so its expand control bought nothing and
cost horizontal space in a narrow view. Drop it, ignore collapse requests on
the root, and tighten the per-level indent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:15:32 +00:00
46206e3047 Show and control channel subscriptions
Track the subscription state from the notifychannelsubscribed events, draw
subscribed and unsubscribed channels apart with the pack's icons (and the
built-in glyph as a hollow cone), and offer subscribe/unsubscribe entries —
channel and family — in the channel context menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:57:25 +00:00
245ae5ecc5 Add an away button with global actions and message presets
The toolbar gains an away toggle with a drop-down: it toggles away on the
current server with no message, while the menu carries the global actions
("Set Globally Away", "Set Globally Away Status"), the saved presets and
their editor. Presets live in ~/.ts3jclient/away.properties and are
managed in a list with add/remove and double-click renaming.

Clients in the channel tree now show their away message in brackets after
the nickname instead of their primary server group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:40:19 +00:00
25a78f52fe Show our own mute, away and commander state right away
The server sends notifyclientupdated to the other clients only, so a
clientupdate of our own never came back to us and our row in the tree kept
its old icons until something else refreshed it. Apply the change to the
local model as we send it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:46:55 +00:00
676e95023e Draw the interface with TeamSpeak icon packs
Icon packs are read in TeamSpeak's own format — a zip (or unpacked folder)
of SVG/PNG art plus a settings.ini mapping icon keys to files — so the packs
of an installed client and the ones from its add-on site work unchanged.
Legacy packs without that mapping (default.zip) are resolved by their file
naming convention instead, picking the resolution closest to the drawn size.

The pack draws the tree, toolbar, menus, context menus, file browser and the
default group icons; a pack's FALLBACK option decides whether what it lacks
comes from default.zip, and anything still missing falls back to the icons
the client draws itself. Options → Design picks the pack and shows a viewer
of everything in it.

Vector art is rasterised with JSVG, and the pack search roots are shared
with the sound packs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:46:39 +00:00
137 changed files with 18399 additions and 2638 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ out/
# The bundled proprietary TeamSpeak 3 client (reference binary, not our source) # The bundled proprietary TeamSpeak 3 client (reference binary, not our source)
/TeamSpeak3-Client-linux_amd64/ /TeamSpeak3-Client-linux_amd64/
/re-android/
# Packages # Packages
*.jar *.jar

View File

@@ -80,6 +80,35 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
- **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`, …)
@@ -93,6 +122,21 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
- Sounds are mixed onto one playback line, so overlapping events never fight over - Sounds are mixed onto one playback line, so overlapping events never fight over
the device; pack volume is separate from the voice volume. the device; pack volume is separate from the voice volume.
### Icon packs
- **Icon packs** in TeamSpeak's own format: a `.zip` (or unpacked folder) of SVG/PNG
artwork plus a `settings.ini` mapping icon keys (`CHANNEL_GREEN`, `PLAYER_ON`,
`CONNECT`, …) to files. Packs are picked up from `~/.ts3jclient/gfx`, an installed
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`,
`default_mono_2014.zip`, the legacy `default.zip`) work unchanged.
- 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
art is rasterised at the size it is drawn at; a pack's `FALLBACK` option decides
whether icons it lacks come from `default.zip`, and anything still missing falls
back to the icons the client draws itself.
- **Pick one in Options → Design**, which also shows a viewer of everything the pack
contains; switching redraws the window right away.
## Requirements ## Requirements
- Java 26+ (developed/tested on Temurin 26) - Java 26+ (developed/tested on Temurin 26)
- The native Opus library on the system: - The native Opus library on the system:
@@ -133,6 +177,15 @@ core/ com.ts3client
│ ├── VoiceOutput voice-packet playback sink │ ├── VoiceOutput voice-packet playback sink
│ ├── OpusParameters live-tunable encoder settings │ ├── OpusParameters live-tunable encoder settings
│ └── SpeechDetector feature-based speech-probability VAD (Automatic/Hybrid) │ └── SpeechDetector feature-based speech-probability VAD (Automatic/Hybrid)
├── gfx
│ ├── IconPack an icon pack zip/folder + its settings.ini mapping
│ └── IconPacks discovery of installed packs
├── 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
@@ -146,14 +199,22 @@ core/ com.ts3client
├── ChannelNode/ClientEntry view models ├── ChannelNode/ClientEntry view models
└── ConnectionListener frontend callbacks └── ConnectionListener frontend callbacks
desktop/ com.ts3client.audio.desktop desktop/ com.ts3client.audio.desktop + com.ts3client.hotkey.desktop
├── Opus Panama (FFM) binding to native libopus ├── Opus Panama (FFM) binding to native libopus
├── OpusEncoder/OpusDecoder thin codec wrappers ├── OpusEncoder/OpusDecoder thin codec wrappers
├── 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 ├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode
├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing ├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing
├── WavSoundPlayer sound-pack playback: decode, resample and mix on one line ├── WavSoundPlayer sound-pack playback: decode, resample and mix on one line
── JavaSoundAudioBackend wires the above into the core AudioBackend ── JavaSoundAudioBackend wires the above into the core AudioBackend
└── hotkey.desktop
├── 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)
@@ -164,13 +225,19 @@ swing/ com.ts3client
├── 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 audio + VAD options with live meter
├── 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)
├── 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)
├── IdentityChooser identity drop-down shared by connect/bookmark forms ├── IdentityChooser identity drop-down shared by connect/bookmark forms
├── LevelMeter dBFS meter with threshold marker ├── LevelMeter dBFS meter with threshold marker
├── Icons programmatic vector icons (no image assets) ├── IconTheme active icon pack: rasterising (JSVG) and caching
├── Icons icon lookup by TeamSpeak key, with drawn fallbacks
└── Theme palette + fonts └── Theme palette + fonts
``` ```
@@ -178,9 +245,17 @@ swing/ com.ts3client
- The **Automatic/Hybrid** VAD uses a lightweight energy/spectral detector rather - The **Automatic/Hybrid** VAD uses a lightweight energy/spectral detector rather
than the WebRTC GMM model the official client ships; it is intentionally than the WebRTC GMM model the official client ships; it is intentionally
dependency-free and reusable in the core library. dependency-free and reusable in the core library.
- Group display shows names; **group icons** are not rendered.
- 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. - Playback decodes streams as mono; stereo music-bot audio is down-mixed.
- Push-to-talk is captured via Swing key events, so it only works while the app - Hotkey actions TeamSpeak offers but this client does not perform yet (listed but
window has focus (no global hotkey). greyed out in the dialog): capture/playback/hotkey profiles and sound packs,
whisper and push-to-whisper, recording, plugins, server groups, talk power, 3D
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.
- No file transfer, avatars, or server/channel administration UI yet. - No file transfer, avatars, or server/channel administration UI yet.

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,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

@@ -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

@@ -18,11 +18,20 @@ 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();
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. */
@@ -50,12 +59,19 @@ public interface VoiceInput extends Microphone {
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);
} }

View File

@@ -0,0 +1,99 @@
package com.ts3client.audio.vad;
import static com.ts3client.audio.vad.VadConstants.NUM_LPC_COEFFICIENTS;
/**
* Linear-prediction analysis for the pitch search, ported from WebRTC's
* {@code rnn_vad/lp_residual.cc}.
*
* <p>Whitening the signal before searching for a pitch makes the auto-correlation peak at
* the true period rather than at whichever formant happens to dominate.
*/
final class LinearPrediction {
private final float[] autoCorr = new float[NUM_LPC_COEFFICIENTS];
private final float[] reflection = new float[NUM_LPC_COEFFICIENTS - 1];
/** Computes the 5 post-processed inverse-filter coefficients for {@code x}. */
void computeLpcCoefficients(float[] x, float[] lpcCoeffs) {
for (int lag = 0; lag < NUM_LPC_COEFFICIENTS; lag++) {
float sum = 0;
for (int i = 0; i < x.length - lag; i++) {
sum += x[i] * x[i + lag];
}
autoCorr[lag] = sum;
}
if (autoCorr[0] == 0.0f) { // Empty frame.
java.util.Arrays.fill(lpcCoeffs, 0.0f);
return;
}
// Assume a -40 dB white noise floor.
autoCorr[0] *= 1.0001f;
autoCorr[1] -= autoCorr[1] * 0.000064f;
autoCorr[2] -= autoCorr[2] * 0.000256f;
autoCorr[3] -= autoCorr[3] * 0.000576f;
autoCorr[4] -= autoCorr[4] * 0.001024f;
java.util.Arrays.fill(reflection, 0.0f);
computeInitialInverseFilterCoefficients();
// Bandwidth expansion, then convolution with (1 + 0.8 z^-1).
reflection[0] *= 0.9f;
reflection[1] *= 0.9f * 0.9f;
reflection[2] *= 0.9f * 0.9f * 0.9f;
reflection[3] *= 0.9f * 0.9f * 0.9f * 0.9f;
final float c = 0.8f;
lpcCoeffs[0] = reflection[0] + c;
lpcCoeffs[1] = reflection[1] + c * reflection[0];
lpcCoeffs[2] = reflection[2] + c * reflection[1];
lpcCoeffs[3] = reflection[3] + c * reflection[2];
lpcCoeffs[4] = c * reflection[3];
}
/** Levinson&ndash;Durbin recursion over {@link #autoCorr}, writing {@link #reflection}. */
private void computeInitialInverseFilterCoefficients() {
float error = autoCorr[0];
for (int i = 0; i < NUM_LPC_COEFFICIENTS - 1; i++) {
float reflectionCoeff = 0;
for (int j = 0; j < i; j++) {
reflectionCoeff += reflection[j] * autoCorr[i - j];
}
reflectionCoeff += autoCorr[i + 1];
// Avoid dividing by numbers close to zero.
final float minErrorMagnitude = 1e-6f;
if (Math.abs(error) < minErrorMagnitude) {
error = Math.copySign(minErrorMagnitude, error);
}
reflectionCoeff /= -error;
reflection[i] = reflectionCoeff;
for (int j = 0; j < ((i + 1) >> 1); j++) {
float tmp1 = reflection[j];
float tmp2 = reflection[i - 1 - j];
reflection[j] = tmp1 + reflectionCoeff * tmp2;
reflection[i - 1 - j] = tmp2 + reflectionCoeff * tmp1;
}
error -= reflectionCoeff * reflectionCoeff * error;
if (error < 0.001f * autoCorr[0]) {
break;
}
}
}
/**
* Applies the inverse filter: {@code y[i] = x[i] + sum_j lpc[j] * x[i - 1 - j]},
* with the sum truncated near the start of the buffer.
*/
static void computeLpResidual(float[] lpcCoeffs, float[] x, float[] y) {
for (int i = 0; i < x.length; i++) {
float sum = x[i];
int taps = Math.min(i, NUM_LPC_COEFFICIENTS);
for (int j = 0; j < taps; j++) {
sum += lpcCoeffs[j] * x[i - 1 - j];
}
y[i] = sum;
}
}
}

View File

@@ -0,0 +1,329 @@
package com.ts3client.audio.vad;
import static com.ts3client.audio.vad.VadConstants.BUF_SIZE_12K;
import static com.ts3client.audio.vad.VadConstants.FRAME_20MS_12K;
import static com.ts3client.audio.vad.VadConstants.FRAME_20MS_24K;
import static com.ts3client.audio.vad.VadConstants.INITIAL_NUM_LAGS_24K;
import static com.ts3client.audio.vad.VadConstants.MAX_PITCH_24K;
import static com.ts3client.audio.vad.VadConstants.MAX_PITCH_48K;
import static com.ts3client.audio.vad.VadConstants.MIN_PITCH_24K;
import static com.ts3client.audio.vad.VadConstants.MIN_PITCH_48K;
import static com.ts3client.audio.vad.VadConstants.NUM_LAGS_12K;
import static com.ts3client.audio.vad.VadConstants.REFINE_NUM_LAGS_24K;
/**
* Pitch period estimation for {@code rnn_vad}, ported from WebRTC's
* {@code rnn_vad/pitch_search.cc} and {@code pitch_search_internal.cc}.
*
* <p>Three stages: a coarse search over the 12&nbsp;kHz decimated LP residual picks two
* candidates, those are refined against the 24&nbsp;kHz buffer, and finally sub-harmonics
* of the winner are tested so that an octave error can be corrected. The result is a
* period expressed at 48&nbsp;kHz, which is what the network was trained on.
*
* <p>Lags are stored inverted: index 0 corresponds to the maximum pitch period.
*/
final class PitchEstimator {
/**
* For each divisor k in [2, 15], the multiplier n such that n*T/k is a sub-harmonic
* worth checking alongside T/k, chosen so no multiple is visited twice.
*/
private static final int[] SUB_HARMONIC_MULTIPLIERS =
{3, 2, 3, 2, 5, 2, 3, 2, 3, 2, 5, 2, 3, 2};
/** Thresholds on the initial period, computed as {@code [5*k*k for k in range(16)]}. */
private static final int[] INITIAL_PITCH_PERIOD_THRESHOLDS =
{20, 45, 80, 125, 180, 245, 320, 405, 500, 605, 720, 845, 980, 1125};
/** Number of pitch periods analysed either side of a candidate during refinement. */
private static final int PITCH_NEIGHBORHOOD_RADIUS = 2;
private final float[] pitchBuffer12k = new float[BUF_SIZE_12K];
private final float[] autoCorrelation12k = new float[NUM_LAGS_12K];
private final float[] yEnergy24k = new float[REFINE_NUM_LAGS_24K];
private final float[] autoCorrelation24k = new float[INITIAL_NUM_LAGS_24K];
private final int[] invertedLagsIndex = new int[2 * (2 * PITCH_NEIGHBORHOOD_RADIUS + 1)];
private int invertedLagsCount;
// Pitch tracking: the previous estimate biases the sub-harmonic decision.
private int lastPeriod48k;
private float lastStrength;
void reset() {
lastPeriod48k = 0;
lastStrength = 0;
}
/** Returns the pitch period at 48 kHz for the given 24 kHz LP residual buffer. */
int estimate(float[] pitchBuffer) {
// Coarse search at 12 kHz. WebRTC decimates without an anti-aliasing filter here;
// the refinement stage below is what recovers the accuracy.
for (int i = 0; i < BUF_SIZE_12K; i++) {
pitchBuffer12k[i] = pitchBuffer[2 * i];
}
computeAutoCorrelation12k();
long candidates = computePitchPeriod12kHz();
int best = (int) (candidates >> 32) * 2;
int secondBest = (int) candidates * 2;
computeSlidingFrameSquareEnergies24k(pitchBuffer);
int pitchLag48k = computePitchPeriod48kHz(pitchBuffer, best, secondBest);
computeExtendedPitchPeriod48kHz(pitchBuffer, MAX_PITCH_48K - pitchLag48k);
return lastPeriod48k;
}
/**
* {@code autoCorrelation12k[lag]} is the correlation of the most recent 20 ms with the
* frame starting {@code lag} samples into the buffer.
*/
private void computeAutoCorrelation12k() {
final int refOffset = BUF_SIZE_12K - FRAME_20MS_12K;
for (int lag = 0; lag < NUM_LAGS_12K; lag++) {
float sum = 0;
for (int i = 0; i < FRAME_20MS_12K; i++) {
sum += pitchBuffer12k[refOffset + i] * pitchBuffer12k[lag + i];
}
autoCorrelation12k[lag] = sum;
}
}
/** Returns the best and second-best inverted lags packed into a long. */
private long computePitchPeriod12kHz() {
float denominator = 1.0f;
for (int i = 0; i < FRAME_20MS_12K + 1; i++) {
denominator += pitchBuffer12k[i] * pitchBuffer12k[i];
}
int bestLag = 0;
float bestNumerator = -1.0f;
float bestDenominator = 0.0f;
int secondLag = 1;
float secondNumerator = -1.0f;
float secondDenominator = 0.0f;
for (int invertedLag = 0; invertedLag < NUM_LAGS_12K; invertedLag++) {
// A pitch candidate must have positive correlation.
if (autoCorrelation12k[invertedLag] > 0.0f) {
float numerator = autoCorrelation12k[invertedLag] * autoCorrelation12k[invertedLag];
// Compare numerator/denominator ratios without dividing.
if (numerator * secondDenominator > secondNumerator * denominator) {
if (numerator * bestDenominator > bestNumerator * denominator) {
secondLag = bestLag;
secondNumerator = bestNumerator;
secondDenominator = bestDenominator;
bestLag = invertedLag;
bestNumerator = numerator;
bestDenominator = denominator;
} else {
secondLag = invertedLag;
secondNumerator = numerator;
secondDenominator = denominator;
}
}
}
// Slide the energy window to the next inverted lag.
float yOld = pitchBuffer12k[invertedLag];
float yNew = pitchBuffer12k[invertedLag + FRAME_20MS_12K];
denominator -= yOld * yOld;
denominator += yNew * yNew;
denominator = Math.max(0.0f, denominator);
}
return ((long) bestLag << 32) | (secondLag & 0xffffffffL);
}
/** Energy of the sliding 20 ms frame for every inverted lag. */
private void computeSlidingFrameSquareEnergies24k(float[] pitchBuffer) {
float yy = 0;
for (int i = 0; i < FRAME_20MS_24K; i++) {
yy += pitchBuffer[i] * pitchBuffer[i];
}
yEnergy24k[0] = yy;
for (int invertedLag = 0; invertedLag < MAX_PITCH_24K; invertedLag++) {
yy -= pitchBuffer[invertedLag] * pitchBuffer[invertedLag];
yy += pitchBuffer[invertedLag + FRAME_20MS_24K] * pitchBuffer[invertedLag + FRAME_20MS_24K];
yy = Math.max(1.0f, yy);
yEnergy24k[invertedLag + 1] = yy;
}
}
/** Correlation of the most recent 20 ms with the frame at {@code invertedLag}. */
private static float autoCorrelation(int invertedLag, float[] pitchBuffer) {
float sum = 0;
for (int i = 0; i < FRAME_20MS_24K; i++) {
sum += pitchBuffer[MAX_PITCH_24K + i] * pitchBuffer[invertedLag + i];
}
return sum;
}
/**
* Recomputes the auto-correlation around both candidates at 24 kHz and returns the
* strongest inverted lag, pseudo-interpolated to 48 kHz.
*/
private int computePitchPeriod48kHz(float[] pitchBuffer, int bestCandidate, int secondCandidate) {
invertedLagsCount = 0;
// Order the two neighbourhoods so the first precedes the second.
boolean swap = bestCandidate > secondCandidate;
int firstCenter = swap ? secondCandidate : bestCandidate;
int secondCenter = swap ? bestCandidate : secondCandidate;
int r1min = Math.max(firstCenter - PITCH_NEIGHBORHOOD_RADIUS, 0);
int r1max = Math.min(firstCenter + PITCH_NEIGHBORHOOD_RADIUS, INITIAL_NUM_LAGS_24K - 1);
int r2min = Math.max(secondCenter - PITCH_NEIGHBORHOOD_RADIUS, 0);
int r2max = Math.min(secondCenter + PITCH_NEIGHBORHOOD_RADIUS, INITIAL_NUM_LAGS_24K - 1);
if (r1max + 1 >= r2min) { // Overlapping or adjacent ranges.
computeAutoCorrelationRange(pitchBuffer, r1min, r2max);
} else {
computeAutoCorrelationRange(pitchBuffer, r1min, r1max);
computeAutoCorrelationRange(pitchBuffer, r2min, r2max);
}
int bestInvertedLag = 0;
float bestNumerator = -1.0f;
float bestDenominator = 0.0f;
for (int i = 0; i < invertedLagsCount; i++) {
int invertedLag = invertedLagsIndex[i];
if (autoCorrelation24k[invertedLag] > 0.0f) {
float numerator = autoCorrelation24k[invertedLag] * autoCorrelation24k[invertedLag];
float denominator = yEnergy24k[invertedLag];
if (numerator * bestDenominator > bestNumerator * denominator) {
bestInvertedLag = invertedLag;
bestNumerator = numerator;
bestDenominator = denominator;
}
}
}
if (bestInvertedLag == 0 || bestInvertedLag >= INITIAL_NUM_LAGS_24K - 1) {
return bestInvertedLag * 2; // Cannot interpolate at the boundaries.
}
int offset = pseudoInterpolationOffset(
autoCorrelation24k[bestInvertedLag + 1],
autoCorrelation24k[bestInvertedLag],
autoCorrelation24k[bestInvertedLag - 1]);
return 2 * bestInvertedLag + offset;
}
private void computeAutoCorrelationRange(float[] pitchBuffer, int min, int max) {
// The pseudo-interpolation reads one lag either side, so zero those rather than
// clearing the whole array.
if (min > 0) {
autoCorrelation24k[min - 1] = 0.0f;
}
if (max < INITIAL_NUM_LAGS_24K - 1) {
autoCorrelation24k[max + 1] = 0.0f;
}
for (int invertedLag = min; invertedLag <= max; invertedLag++) {
autoCorrelation24k[invertedLag] = autoCorrelation(invertedLag, pitchBuffer);
invertedLagsIndex[invertedLagsCount++] = invertedLag;
}
}
/** Returns a lag correction in {-1, 0, +1} based on the neighbouring correlations. */
private static int pseudoInterpolationOffset(float prev, float curr, float next) {
if ((next - prev) > 0.7f * (curr - prev)) {
return 1;
} else if ((prev - next) > 0.7f * (curr - next)) {
return -1;
}
return 0;
}
/** Refines {@code lag} (24 kHz) to a 48 kHz lag using pseudo-interpolation. */
private static int pseudoInterpolationLagPitchBuf(int lag, float[] pitchBuffer) {
int offset = 0;
if (lag > 0 && lag < MAX_PITCH_24K) {
int invertedLag = MAX_PITCH_24K - lag;
offset = pseudoInterpolationOffset(
autoCorrelation(invertedLag + 1, pitchBuffer),
autoCorrelation(invertedLag, pitchBuffer),
autoCorrelation(invertedLag - 1, pitchBuffer));
}
return 2 * lag + offset;
}
/** Same as {@code round(multiplier * pitchPeriod / divisor)}. */
private static int alternativePitchPeriod(int pitchPeriod, int multiplier, int divisor) {
return (2 * multiplier * pitchPeriod + divisor) / (2 * divisor);
}
/**
* Tests sub-harmonics of the refined period and keeps the strongest, then stores the
* winner in {@link #lastPeriod48k}/{@link #lastStrength}.
*/
private void computeExtendedPitchPeriod48kHz(float[] pitchBuffer, int initialPeriod48k) {
final float xEnergy = yEnergy24k[MAX_PITCH_24K];
int bestPeriod = Math.min(initialPeriod48k / 2, MAX_PITCH_24K - 1);
float bestXy = autoCorrelation(MAX_PITCH_24K - bestPeriod, pitchBuffer);
float bestYEnergy = yEnergy24k[MAX_PITCH_24K - bestPeriod];
float bestStrength = pitchStrength(bestXy, bestYEnergy, xEnergy);
final int initialPeriod = bestPeriod;
final float initialStrength = bestStrength;
// The tracked pitch is kept at 48 kHz but compared at 24 kHz.
final int lastPeriod = lastPeriod48k / 2;
// Largest divisor for which the alternative period is still above the minimum.
int maxPeriodDivisor = (2 * initialPeriod) / (2 * MIN_PITCH_24K - 1);
for (int periodDivisor = 2; periodDivisor <= maxPeriodDivisor; periodDivisor++) {
int alternativePeriod = alternativePitchPeriod(initialPeriod, 1, periodDivisor);
int dualPeriod = alternativePitchPeriod(
initialPeriod, SUB_HARMONIC_MULTIPLIERS[periodDivisor - 2], periodDivisor);
// Special case: for a divisor of 2 the sub-harmonic can exceed the pitch range.
if (periodDivisor == 2 && dualPeriod > MAX_PITCH_24K) {
dualPeriod = initialPeriod;
}
// Score the candidate together with its sub-harmonic.
float xyPrimary = autoCorrelation(MAX_PITCH_24K - alternativePeriod, pitchBuffer);
float xySecondary = autoCorrelation(MAX_PITCH_24K - dualPeriod, pitchBuffer);
float xy = 0.5f * (xyPrimary + xySecondary);
float yy = 0.5f * (yEnergy24k[MAX_PITCH_24K - alternativePeriod]
+ yEnergy24k[MAX_PITCH_24K - dualPeriod]);
float alternativeStrength = pitchStrength(xy, yy, xEnergy);
if (isAlternativeStronger(lastPeriod, initialPeriod, initialStrength,
alternativePeriod, alternativeStrength, periodDivisor)) {
bestPeriod = alternativePeriod;
bestStrength = alternativeStrength;
bestXy = xy;
bestYEnergy = yy;
}
}
bestXy = Math.max(0.0f, bestXy);
float finalStrength = (bestYEnergy <= bestXy) ? 1.0f : bestXy / (bestYEnergy + 1.0f);
finalStrength = Math.min(bestStrength, finalStrength);
lastPeriod48k = Math.max(MIN_PITCH_48K, pseudoInterpolationLagPitchBuf(bestPeriod, pitchBuffer));
lastStrength = finalStrength;
}
private static float pitchStrength(float xy, float yEnergy, float xEnergy) {
return xy / (float) Math.sqrt(1.0f + xEnergy * yEnergy);
}
private boolean isAlternativeStronger(int lastPeriod, int initialPeriod, float initialStrength,
int alternativePeriod, float alternativeStrength,
int periodDivisor) {
// Pitch tracking: a candidate close to the previous estimate clears a lower bar.
float lowerThresholdTerm = 0.0f;
int distance = Math.abs(alternativePeriod - lastPeriod);
if (distance <= 1) {
lowerThresholdTerm = lastStrength;
} else if (distance == 2
&& initialPeriod > INITIAL_PITCH_PERIOD_THRESHOLDS[periodDivisor - 2]) {
lowerThresholdTerm = 0.5f * lastStrength;
}
// Higher frequencies get a stricter threshold: short-term correlations bias
// the search towards them and cause false positives.
float threshold = Math.max(0.3f, 0.7f * initialStrength - lowerThresholdTerm);
if (alternativePeriod < 3 * MIN_PITCH_24K) {
threshold = Math.max(0.4f, 0.85f * initialStrength - lowerThresholdTerm);
} else if (alternativePeriod < 2 * MIN_PITCH_24K) {
threshold = Math.max(0.5f, 0.9f * initialStrength - lowerThresholdTerm);
}
return alternativeStrength > threshold;
}
}

View File

@@ -0,0 +1,224 @@
package com.ts3client.audio.vad;
/**
* Real-input FFT for the {@code rnn_vad} spectral analysis.
*
* <p>The transform length is 480 (a 20&nbsp;ms frame at 24&nbsp;kHz), which is not a
* power of two, so the shared radix-2 {@code Fft} cannot be used. This is a
* mixed-radix Cooley&ndash;Tukey complex FFT (radices 2, 3, 4 and 5, with a generic
* DFT for any other factor) wrapped in the standard real-input trick: an
* {@code n}-point real transform is computed as an {@code n/2}-point complex one
* followed by a split step.
*
* <p>Output uses the packed layout WebRTC's PFFFT produces, which the band
* correlator indexes directly:
* <pre>
* out[0] = Re(X[0]) (DC)
* out[1] = Re(X[n/2]) (Nyquist)
* out[2k] = Re(X[k])
* out[2k+1] = Im(X[k]) for k in [1, n/2)
* </pre>
*/
final class RealFft {
private final int size;
private final int half;
// Twiddles for the complex sub-transform and for the real split step.
private final float[] halfRe;
private final float[] halfIm;
private final float[] splitCos;
private final float[] splitSin;
private final ComplexFft complexFft;
RealFft(int size) {
if (size < 2 || (size & 1) != 0) {
throw new IllegalArgumentException("Real FFT length must be even: " + size);
}
this.size = size;
this.half = size / 2;
this.halfRe = new float[half];
this.halfIm = new float[half];
this.complexFft = new ComplexFft(half);
this.splitCos = new float[half / 2 + 1];
this.splitSin = new float[half / 2 + 1];
for (int k = 0; k <= half / 2; k++) {
double angle = -2.0 * Math.PI * k / size;
splitCos[k] = (float) Math.cos(angle);
splitSin[k] = (float) Math.sin(angle);
}
}
int size() {
return size;
}
/**
* Transforms {@code in} ({@link #size} real samples) into {@code out}
* ({@link #size} floats in the packed layout described above).
*/
void forward(float[] in, float[] out) {
// Pack the real signal as half/2 complex samples: z[i] = x[2i] + j*x[2i+1].
for (int i = 0; i < half; i++) {
halfRe[i] = in[2 * i];
halfIm[i] = in[2 * i + 1];
}
complexFft.forward(halfRe, halfIm);
// Split Z into the transforms of the even and odd sample sequences, then
// recombine them into the spectrum of the original real signal.
out[0] = halfRe[0] + halfIm[0];
out[1] = halfRe[0] - halfIm[0];
for (int k = 1; k <= half / 2; k++) {
int mirror = half - k;
// Even part: hermitian-symmetric average; odd part: the difference.
float evenRe = 0.5f * (halfRe[k] + halfRe[mirror]);
float evenIm = 0.5f * (halfIm[k] - halfIm[mirror]);
float oddRe = 0.5f * (halfIm[k] + halfIm[mirror]);
float oddIm = -0.5f * (halfRe[k] - halfRe[mirror]);
float c = splitCos[k];
float s = splitSin[k];
float twRe = oddRe * c - oddIm * s;
float twIm = oddRe * s + oddIm * c;
float re = evenRe + twRe;
float im = evenIm + twIm;
out[2 * k] = re;
out[2 * k + 1] = im;
if (mirror != k) {
// X[n/2 - k] is the conjugate-mirrored companion of X[k].
out[2 * mirror] = evenRe - twRe;
out[2 * mirror + 1] = -(evenIm - twIm);
}
}
}
/**
* Mixed-radix complex FFT, decimation in time.
*
* <p>A transform of length {@code n = r * m} is computed by transforming the {@code r}
* interleaved subsequences of length {@code m} and combining them:
* {@code X[k1*m + k2] = sum_j (W_n^(j*k2) * X_j[k2]) * W_r^(j*k1)}.
*
* <p>The recursion is expressed directly rather than as an iterative digit-reversed
* loop, which keeps it correct for any factorisation. Scratch buffers are allocated
* once per recursion level so no transform allocates.
*/
static final class ComplexFft {
private final int n;
private final float[] cosTable;
private final float[] sinTable;
private final float[][] scratchRe;
private final float[][] scratchIm;
private final float[] dftRe;
private final float[] dftIm;
ComplexFft(int n) {
this.n = n;
this.cosTable = new float[n];
this.sinTable = new float[n];
for (int i = 0; i < n; i++) {
double angle = -2.0 * Math.PI * i / n;
cosTable[i] = (float) Math.cos(angle);
sinTable[i] = (float) Math.sin(angle);
}
int depth = 0;
int maxRadix = 1;
for (int rest = n; rest > 1; depth++) {
int r = smallestFactor(rest);
maxRadix = Math.max(maxRadix, r);
rest /= r;
}
this.scratchRe = new float[depth + 1][n];
this.scratchIm = new float[depth + 1][n];
this.dftRe = new float[maxRadix];
this.dftIm = new float[maxRadix];
}
/** Transforms {@code re}/{@code im} of length {@code n} in place. */
void forward(float[] re, float[] im) {
transform(re, im, 0, 1, n, scratchRe[0], scratchIm[0], 0, 1);
System.arraycopy(scratchRe[0], 0, re, 0, n);
System.arraycopy(scratchIm[0], 0, im, 0, n);
}
/**
* Transforms the {@code len} samples starting at {@code inOff} with the given
* {@code stride}, writing the spectrum to {@code outRe}/{@code outIm} at
* {@code outOff}.
*/
private void transform(float[] inRe, float[] inIm, int inOff, int stride, int len,
float[] outRe, float[] outIm, int outOff, int depth) {
if (len == 1) {
outRe[outOff] = inRe[inOff];
outIm[outOff] = inIm[inOff];
return;
}
int r = smallestFactor(len);
int m = len / r;
// Sub-transforms of the r interleaved subsequences, laid out end to end.
float[] subRe = scratchRe[depth];
float[] subIm = scratchIm[depth];
for (int j = 0; j < r; j++) {
transform(inRe, inIm, inOff + j * stride, stride * r, m,
subRe, subIm, outOff + j * m, depth + 1);
}
// Twiddle step for this transform size, expressed in top-level table units.
int unit = n / len;
for (int k2 = 0; k2 < m; k2++) {
for (int j = 0; j < r; j++) {
int idx = outOff + j * m + k2;
int t = (j * k2 * unit) % n;
float c = cosTable[t];
float s = sinTable[t];
dftRe[j] = subRe[idx] * c - subIm[idx] * s;
dftIm[j] = subRe[idx] * s + subIm[idx] * c;
}
combine(outRe, outIm, outOff + k2, m, r);
}
}
/** Length-{@code r} DFT of {@code dftRe}/{@code dftIm}, written with stride {@code m}. */
private void combine(float[] outRe, float[] outIm, int outOff, int m, int r) {
switch (r) {
case 2 -> {
outRe[outOff] = dftRe[0] + dftRe[1];
outIm[outOff] = dftIm[0] + dftIm[1];
outRe[outOff + m] = dftRe[0] - dftRe[1];
outIm[outOff + m] = dftIm[0] - dftIm[1];
}
default -> {
int unit = n / r;
for (int k1 = 0; k1 < r; k1++) {
float sumRe = 0;
float sumIm = 0;
for (int j = 0; j < r; j++) {
int t = (j * k1 % r) * unit;
float c = cosTable[t];
float s = sinTable[t];
sumRe += dftRe[j] * c - dftIm[j] * s;
sumIm += dftRe[j] * s + dftIm[j] * c;
}
outRe[outOff + k1 * m] = sumRe;
outIm[outOff + k1 * m] = sumIm;
}
}
}
}
private static int smallestFactor(int v) {
for (int f = 2; f * f <= v; f++) {
if (v % f == 0) return f;
}
return v;
}
}
}

View File

@@ -0,0 +1,203 @@
package com.ts3client.audio.vad;
/**
* The {@code rnn_vad} network: a 42&rarr;24 dense layer, a 24-unit GRU and a 24&rarr;1
* dense output. Ported from WebRTC's {@code rnn_vad/rnn.cc}, {@code rnn_fc.cc} and
* {@code rnn_gru.cc}, using the activation approximations from RNNoise.
*
* <p>The GRU carries state between frames, so the caller must feed frames in order and
* {@link #reset()} on a discontinuity. Silence resets the state, matching WebRTC.
*/
final class RnnNetwork {
private static final int INPUT_SIZE = 42;
private static final int FC1_SIZE = 24;
private static final int GRU_SIZE = 24;
private static final int NUM_GATES = 3; // update, reset, output
private final float[] fc1Weights; // [output][input]
private final float[] fc1Bias;
private final float[] fc2Weights;
private final float[] fc2Bias;
private final float[] gruWeights; // [gate][output][input]
private final float[] gruRecurrentWeights; // [gate][output][output]
private final float[] gruBias; // [gate][output]
private final float[] fc1Output = new float[FC1_SIZE];
private final float[] state = new float[GRU_SIZE];
private final float[] update = new float[GRU_SIZE];
private final float[] resetGate = new float[GRU_SIZE];
private final float[] resetTimesState = new float[GRU_SIZE];
RnnNetwork() {
fc1Weights = transposeAndScale(RnnVadWeights.INPUT_DENSE_WEIGHTS, INPUT_SIZE, FC1_SIZE);
fc1Bias = scale(RnnVadWeights.INPUT_DENSE_BIAS);
// The output layer is 24x1, so its stored layout already matches.
fc2Weights = scale(RnnVadWeights.OUTPUT_DENSE_WEIGHTS);
fc2Bias = scale(RnnVadWeights.OUTPUT_DENSE_BIAS);
gruWeights = transposeGruTensor(RnnVadWeights.HIDDEN_GRU_WEIGHTS, FC1_SIZE, GRU_SIZE);
gruRecurrentWeights =
transposeGruTensor(RnnVadWeights.HIDDEN_GRU_RECURRENT_WEIGHTS, GRU_SIZE, GRU_SIZE);
gruBias = transposeGruTensor(RnnVadWeights.HIDDEN_GRU_BIAS, 1, GRU_SIZE);
}
void reset() {
java.util.Arrays.fill(state, 0.0f);
}
/**
* Returns the speech probability for a feature vector. When {@code silence} is set the
* state is cleared and zero is returned without running the network.
*/
float computeVadProbability(float[] featureVector, boolean silence) {
if (silence) {
reset();
return 0.0f;
}
for (int o = 0; o < FC1_SIZE; o++) {
float x = fc1Bias[o] + dot(featureVector, fc1Weights, o * INPUT_SIZE, INPUT_SIZE);
fc1Output[o] = tansigApproximated(x);
}
computeGruOutput(fc1Output);
float x = fc2Bias[0] + dot(state, fc2Weights, 0, GRU_SIZE);
return sigmoidApproximated(x);
}
private void computeGruOutput(float[] input) {
final int strideWeights = FC1_SIZE * GRU_SIZE;
final int strideRecurrent = GRU_SIZE * GRU_SIZE;
// u = sigmoid(W_u.i + R_u.s + b_u)
computeGate(input, 0, 0, 0, update);
// r = sigmoid(W_r.i + R_r.s + b_r)
computeGate(input, GRU_SIZE, strideWeights, strideRecurrent, resetGate);
// s' = u.s + (1 - u).ReLU(W_s.i + R_s.(s.r) + b_s)
for (int o = 0; o < GRU_SIZE; o++) {
resetTimesState[o] = state[o] * resetGate[o];
}
for (int o = 0; o < GRU_SIZE; o++) {
float x = gruBias[2 * GRU_SIZE + o];
x += dot(input, gruWeights, 2 * strideWeights + o * FC1_SIZE, FC1_SIZE);
x += dot(resetTimesState, gruRecurrentWeights,
2 * strideRecurrent + o * GRU_SIZE, GRU_SIZE);
state[o] = update[o] * state[o] + (1.0f - update[o]) * Math.max(0.0f, x);
}
}
private void computeGate(float[] input, int biasOffset, int weightsOffset,
int recurrentOffset, float[] gate) {
for (int o = 0; o < GRU_SIZE; o++) {
float x = gruBias[biasOffset + o];
x += dot(input, gruWeights, weightsOffset + o * FC1_SIZE, FC1_SIZE);
x += dot(state, gruRecurrentWeights, recurrentOffset + o * GRU_SIZE, GRU_SIZE);
gate[o] = sigmoidApproximated(x);
}
}
private static float dot(float[] a, float[] weights, int weightsOffset, int len) {
float sum = 0;
for (int i = 0; i < len; i++) {
sum += a[i] * weights[weightsOffset + i];
}
return sum;
}
// ---- weight layout ----
private static float[] scale(byte[] src) {
float[] out = new float[src.length];
for (int i = 0; i < src.length; i++) {
out[i] = RnnVadWeights.SCALE * src[i];
}
return out;
}
/** Stored as {@code [input][output]}; the dot products want {@code [output][input]}. */
private static float[] transposeAndScale(byte[] src, int inputSize, int outputSize) {
float[] out = new float[src.length];
for (int o = 0; o < outputSize; o++) {
for (int i = 0; i < inputSize; i++) {
out[o * inputSize + i] = RnnVadWeights.SCALE * src[i * outputSize + o];
}
}
return out;
}
/**
* GRU tensors are stored as {@code [n][gate][output]} and are wanted as
* {@code [gate][output][n]}, where {@code n} is the input size (1 for the bias).
*/
private static float[] transposeGruTensor(byte[] src, int n, int outputSize) {
float[] out = new float[src.length];
int strideSrc = NUM_GATES * outputSize;
int strideDst = n * outputSize;
for (int g = 0; g < NUM_GATES; g++) {
for (int o = 0; o < outputSize; o++) {
for (int i = 0; i < n; i++) {
out[g * strideDst + o * n + i] =
RnnVadWeights.SCALE * src[i * strideSrc + g * outputSize + o];
}
}
}
return out;
}
// ---- activations (RNNoise approximations, bit-compatible with WebRTC) ----
/** tanh sampled every 0.04, copied verbatim from RNNoise's {@code rnn_activations.h}. */
private static final float[] TANSIG_TABLE = {
0.000000f, 0.039979f, 0.079830f, 0.119427f, 0.158649f, 0.197375f, 0.235496f, 0.272905f,
0.309507f, 0.345214f, 0.379949f, 0.413644f, 0.446244f, 0.477700f, 0.507977f, 0.537050f,
0.564900f, 0.591519f, 0.616909f, 0.641077f, 0.664037f, 0.685809f, 0.706419f, 0.725897f,
0.744277f, 0.761594f, 0.777888f, 0.793199f, 0.807569f, 0.821040f, 0.833655f, 0.845456f,
0.856485f, 0.866784f, 0.876393f, 0.885352f, 0.893698f, 0.901468f, 0.908698f, 0.915420f,
0.921669f, 0.927473f, 0.932862f, 0.937863f, 0.942503f, 0.946806f, 0.950795f, 0.954492f,
0.957917f, 0.961090f, 0.964028f, 0.966747f, 0.969265f, 0.971594f, 0.973749f, 0.975743f,
0.977587f, 0.979293f, 0.980869f, 0.982327f, 0.983675f, 0.984921f, 0.986072f, 0.987136f,
0.988119f, 0.989027f, 0.989867f, 0.990642f, 0.991359f, 0.992020f, 0.992631f, 0.993196f,
0.993718f, 0.994199f, 0.994644f, 0.995055f, 0.995434f, 0.995784f, 0.996108f, 0.996407f,
0.996682f, 0.996937f, 0.997172f, 0.997389f, 0.997590f, 0.997775f, 0.997946f, 0.998104f,
0.998249f, 0.998384f, 0.998508f, 0.998623f, 0.998728f, 0.998826f, 0.998916f, 0.999000f,
0.999076f, 0.999147f, 0.999213f, 0.999273f, 0.999329f, 0.999381f, 0.999428f, 0.999472f,
0.999513f, 0.999550f, 0.999585f, 0.999617f, 0.999646f, 0.999673f, 0.999699f, 0.999722f,
0.999743f, 0.999763f, 0.999781f, 0.999798f, 0.999813f, 0.999828f, 0.999841f, 0.999853f,
0.999865f, 0.999875f, 0.999885f, 0.999893f, 0.999902f, 0.999909f, 0.999916f, 0.999923f,
0.999929f, 0.999934f, 0.999939f, 0.999944f, 0.999948f, 0.999952f, 0.999956f, 0.999959f,
0.999962f, 0.999965f, 0.999968f, 0.999970f, 0.999973f, 0.999975f, 0.999977f, 0.999978f,
0.999980f, 0.999982f, 0.999983f, 0.999984f, 0.999986f, 0.999987f, 0.999988f, 0.999989f,
0.999990f, 0.999990f, 0.999991f, 0.999992f, 0.999992f, 0.999993f, 0.999994f, 0.999994f,
0.999994f, 0.999995f, 0.999995f, 0.999996f, 0.999996f, 0.999996f, 0.999997f, 0.999997f,
0.999997f, 0.999997f, 0.999997f, 0.999998f, 0.999998f, 0.999998f, 0.999998f, 0.999998f,
0.999998f, 0.999999f, 0.999999f, 0.999999f, 0.999999f, 0.999999f, 0.999999f, 0.999999f,
0.999999f, 0.999999f, 0.999999f, 0.999999f, 0.999999f, 0.999999f, 1.000000f, 1.000000f,
1.000000f, 1.000000f, 1.000000f, 1.000000f, 1.000000f, 1.000000f, 1.000000f, 1.000000f,
1.000000f
};
private static float tansigApproximated(float x) {
// Tests are reversed to catch NaNs.
if (!(x < 8.0f)) {
return 1.0f;
}
if (!(x > -8.0f)) {
return -1.0f;
}
float sign = 1.0f;
if (x < 0.0f) {
x = -x;
sign = -1.0f;
}
int i = (int) Math.floor(0.5f + 25 * x);
float y = TANSIG_TABLE[i];
// Newton step back to x's own scale (undoing the table's factor of 25).
x -= 0.04f * i;
y = y + x * (1.0f - y * y) * (1.0f - y * x);
return sign * y;
}
private static float sigmoidApproximated(float x) {
return 0.5f + 0.5f * tansigApproximated(0.5f * x);
}
}

View File

@@ -0,0 +1,140 @@
package com.ts3client.audio.vad;
import com.ts3client.audio.SpeechProbabilityDetector;
/**
* {@link SpeechProbabilityDetector} backed by {@link RnnVad}, adapting an arbitrary
* capture rate and frame size to the detector's fixed 10&nbsp;ms frames at 24&nbsp;kHz.
*
* <p>Capture frames rarely divide into 10&nbsp;ms chunks exactly, so leftover samples are
* carried into the next call. The reported probability is that of the most recent complete
* 10&nbsp;ms frame; a capture frame long enough to span several of them advances the
* detector several times, exactly as feeding it directly would.
*/
public final class RnnSpeechDetector implements SpeechProbabilityDetector {
/** Half-band FIR used when decimating; long enough to keep aliasing out of the passband. */
private static final int FILTER_TAPS = 32;
private final RnnVad vad = new RnnVad();
private final int sampleRate;
private final boolean decimateByTwo;
private final float[] filterCoeffs;
private final float[] filterState;
private int filterPos;
/** Resampled samples not yet consumed as a whole 10 ms frame. */
private final float[] pending = new float[RnnVad.FRAME_SIZE];
private int pendingCount;
/** Fractional read position, for capture rates that are not a multiple of 24 kHz. */
private double resamplePos;
public RnnSpeechDetector(int sampleRate) {
this.sampleRate = sampleRate;
this.decimateByTwo = sampleRate == 2 * RnnVad.SAMPLE_RATE;
if (decimateByTwo) {
this.filterCoeffs = lowPass(FILTER_TAPS, 0.25);
this.filterState = new float[FILTER_TAPS];
} else {
this.filterCoeffs = null;
this.filterState = null;
}
}
@Override
public double process(float[] frame, int length) {
if (sampleRate == RnnVad.SAMPLE_RATE) {
feed(frame, 0, length, 1);
} else if (decimateByTwo) {
decimateAndFeed(frame, length);
} else {
resampleAndFeed(frame, length);
}
return vad.probability();
}
@Override
public double probability() {
return vad.probability();
}
@Override
public void reset() {
vad.reset();
pendingCount = 0;
resamplePos = 0;
filterPos = 0;
if (filterState != null) {
java.util.Arrays.fill(filterState, 0.0f);
}
}
/** Appends samples (taking every {@code step}-th) and runs the detector per full frame. */
private void feed(float[] src, int offset, int length, int step) {
for (int i = offset; i < offset + length; i += step) {
push(src[i]);
}
}
/** 48 kHz (the common case): low-pass, then keep every other sample. */
private void decimateAndFeed(float[] frame, int length) {
for (int i = 0; i < length; i++) {
filterState[filterPos] = frame[i];
filterPos = (filterPos + 1) % FILTER_TAPS;
if ((i & 1) == 1) {
float acc = 0;
int idx = filterPos;
for (int t = 0; t < FILTER_TAPS; t++) {
acc += filterCoeffs[t] * filterState[idx];
idx = (idx + 1) % FILTER_TAPS;
}
push(acc);
}
}
}
/**
* Any other capture rate. Linear interpolation is enough here: the detector's own
* analysis is far coarser than the resampling error.
*/
private void resampleAndFeed(float[] frame, int length) {
double ratio = (double) sampleRate / RnnVad.SAMPLE_RATE;
while (resamplePos < length) {
int i = (int) resamplePos;
double frac = resamplePos - i;
float a = frame[i];
float b = frame[Math.min(i + 1, length - 1)];
push((float) ((1 - frac) * a + frac * b));
resamplePos += ratio;
}
resamplePos -= length;
}
private void push(float sample) {
pending[pendingCount++] = sample;
if (pendingCount == RnnVad.FRAME_SIZE) {
vad.process(pending, 0);
pendingCount = 0;
}
}
/** Windowed-sinc low-pass with the given normalised cutoff, scaled to unit DC gain. */
private static float[] lowPass(int taps, double cutoff) {
float[] h = new float[taps];
double centre = (taps - 1) / 2.0;
double sum = 0;
for (int i = 0; i < taps; i++) {
double t = i - centre;
double sinc = (t == 0) ? 2 * cutoff : Math.sin(2 * Math.PI * cutoff * t) / (Math.PI * t);
double window = 0.54 - 0.46 * Math.cos(2 * Math.PI * i / (taps - 1)); // Hamming
h[i] = (float) (sinc * window);
sum += h[i];
}
for (int i = 0; i < taps; i++) {
h[i] /= (float) sum;
}
return h;
}
}

View File

@@ -0,0 +1,103 @@
package com.ts3client.audio.vad;
import static com.ts3client.audio.vad.VadConstants.BUF_SIZE_24K;
import static com.ts3client.audio.vad.VadConstants.FEATURE_VECTOR_SIZE;
import static com.ts3client.audio.vad.VadConstants.FRAME_10MS_24K;
import static com.ts3client.audio.vad.VadConstants.FRAME_20MS_24K;
import static com.ts3client.audio.vad.VadConstants.MAX_PITCH_24K;
import static com.ts3client.audio.vad.VadConstants.NUM_LPC_COEFFICIENTS;
/**
* WebRTC's {@code rnn_vad} speech-probability detector, ported to Java.
*
* <p>This is the detector the TeamSpeak 3 client uses for its "Automatic" and "Hybrid"
* voice-activation modes: TS3 links the same network with byte-identical weights, so at a
* given threshold this gates on the same frames theirs does.
*
* <p>Feed exactly {@link VadConstants#FRAME_10MS_24K} samples of 24&nbsp;kHz mono audio in
* [-1, 1] per call, in order. Combines {@code features_extraction.cc} and {@code rnn.cc}.
*/
public final class RnnVad {
/** Samples per call: 10 ms at 24 kHz. */
public static final int FRAME_SIZE = FRAME_10MS_24K;
/** Sample rate this detector expects. */
public static final int SAMPLE_RATE = VadConstants.SAMPLE_RATE_24K;
/**
* WebRTC's audio processing carries float samples on the 16-bit integer scale, and the
* network's thresholds (the silence cut-off, the pitch energy floor) are calibrated for
* it. Callers pass [-1, 1], so scale on the way in.
*/
private static final float INT16_SCALE = 32768.0f;
/**
* Rolling window of 24 kHz samples: one maximum pitch period of history followed by
* the most recent 20 ms.
*/
private final float[] pitchBuffer = new float[BUF_SIZE_24K];
private final float[] lpResidual = new float[BUF_SIZE_24K];
private final float[] lpcCoeffs = new float[NUM_LPC_COEFFICIENTS];
private final float[] featureVector = new float[FEATURE_VECTOR_SIZE];
private final LinearPrediction linearPrediction = new LinearPrediction();
private final PitchEstimator pitchEstimator = new PitchEstimator();
private final SpectralFeatures spectralFeatures = new SpectralFeatures();
private final RnnNetwork network = new RnnNetwork();
private float probability;
private int pitchPeriod48k;
/** Clears all history. Call on a capture discontinuity. */
public void reset() {
java.util.Arrays.fill(pitchBuffer, 0.0f);
pitchEstimator.reset();
pitchPeriod48k = 0;
spectralFeatures.reset();
network.reset();
probability = 0.0f;
}
/** Speech probability in [0, 1] from the most recent {@link #process} call. */
public float probability() {
return probability;
}
/**
* Pitch period of the most recent frame, expressed at 48&nbsp;kHz as the network's
* feature is. Exposed for diagnostics and tests.
*/
public int pitchPeriod48kHz() {
return pitchPeriod48k;
}
/**
* Analyses one 10 ms frame and returns the speech probability in [0, 1].
*
* @param frame mono samples in [-1, 1]
* @param offset index of the first sample
*/
public float process(float[] frame, int offset) {
System.arraycopy(pitchBuffer, FRAME_10MS_24K, pitchBuffer, 0, BUF_SIZE_24K - FRAME_10MS_24K);
int start = BUF_SIZE_24K - FRAME_10MS_24K;
for (int i = 0; i < FRAME_10MS_24K; i++) {
pitchBuffer[start + i] = frame[offset + i] * INT16_SCALE;
}
// Search the pitch on the whitened signal so formants don't dominate the peak.
linearPrediction.computeLpcCoefficients(pitchBuffer, lpcCoeffs);
LinearPrediction.computeLpResidual(lpcCoeffs, pitchBuffer, lpResidual);
pitchPeriod48k = pitchEstimator.estimate(lpResidual);
// Normalisation based on the training-set statistics.
featureVector[FEATURE_VECTOR_SIZE - 2] = 0.01f * (pitchPeriod48k - 300);
// Compare the most recent 20 ms against the 20 ms one pitch period earlier.
int laggedOffset = MAX_PITCH_24K - pitchPeriod48k / 2;
boolean silence = spectralFeatures.checkSilenceComputeFeatures(
pitchBuffer, BUF_SIZE_24K - FRAME_20MS_24K, pitchBuffer, laggedOffset, featureVector);
probability = network.computeVadProbability(featureVector, silence);
return probability;
}
}

View File

@@ -0,0 +1,273 @@
package com.ts3client.audio.vad;
/**
* Quantised weights of WebRTC's {@code rnn_vad} network, copied verbatim from
* {@code third_party/rnnoise/src/rnn_vad_weights.cc} (BSD, Xiph/Jean-Marc Valin).
*
* <p>These are the same 4585 bytes the TeamSpeak 3 client links in, so our detector
* produces the same speech probabilities theirs does.
*
* <p>Values are stored quantised; {@link #SCALE} converts them to float.
*/
final class RnnVadWeights {
/** Dequantisation factor applied to every table below. */
static final float SCALE = 1.0f / 256.0f;
/** FC1 weights, laid out as [input][output] (42 x 24). */
static final byte[] INPUT_DENSE_WEIGHTS = {
-10, 0, -3, 1, -8, -6, 3, -13, 1, 0, -3, -7, -5, -3, 6, -1, -6, 0, -6, -4, -1, -2, 1, 1, -7, 2,
21, 10, -5, -20, 24, 23, 37, 8, -2, 33, -6, 22, 13, -2, 50, 8, 13, 1, -15, 30, -10, 30, 0, 3, 5,
27, 1, 4, -3, 41, 56, 35, -2, 49, -13, 11, 13, -2, -47, 5, -16, -60, -15, 77, -17, 26, -3, 14,
-21, 19, -5, -19, -13, 0, 10, 14, 9, 31, -13, -41, -10, 4, 22, 18, -48, -6, -10, 62, -3, -18,
-14, 12, 26, -28, 3, 14, 25, -13, -19, 6, 5, 36, -3, -65, -12, 0, 31, -7, -9, 101, -4, 26, 16,
17, -12, -12, 14, -36, -3, 5, -15, 21, 2, 30, -3, 38, -4, 1, -6, 7, -7, 14, 38, -22, -30, -3,
-7, 3, -39, -70, -126, 25, 34, 94, -67, -22, -33, 83, -47, -118, 4, 70, 33, 25, 62, -128, -76,
-118, -113, 49, -12, -100, -18, -114, -33, 43, 32, 61, 40, -9, -106, 2, 36, -100, -40, -5, 20,
-75, 61, -51, -9, 126, -27, -52, 5, -24, -21, -126, -114, -12, 15, 106, -2, 73, -125, 50, 13,
-120, 35, 35, 4, -61, 29, -124, 6, -53, -69, -125, 64, -89, 36, -107, -103, -7, 27, 121, 69, 77,
-35, 35, 95, -125, -49, 97, -45, -43, -23, 23, -28, -65, -118, 2, 8, -126, 27, -97, 92, 5, 55,
82, 17, -57, -115, 37, 8, -106, -46, 41, -2, 21, -44, 8, -73, -58, -39, 34, 89, -95, 95, -117,
120, -58, 31, 123, 1, -32, -109, -110, 60, -120, -43, -74, 5, 91, 26, 21, 114, 82, -83, -126,
123, 22, -16, -67, 25, -83, 46, 48, -34, -121, -124, -63, -35, -9, 31, 82, 123, 6, -3, 117, 93,
-2, -13, -36, 124, -112, -6, -102, -5, -33, -15, 44, -69, -127, -23, -40, -34, -85, 68, 83, -1,
40, 8, 84, 118, -58, -55, -102, 123, -55, -14, -123, 44, -63, -14, 21, 35, 16, 24, -126, -13,
-114, 35, 20, -36, 61, -9, 97, 34, 19, -32, -109, 76, -104, 99, -119, 45, -125, -51, -28, -8,
-69, -8, 125, -45, -93, 113, 103, -41, -82, 52, 7, 126, 0, -40, 104, 55, -58, 17, -124, -93,
-58, 8, -45, 1, 56, -123, 108, -47, -23, 115, 127, 17, -68, -13, 116, -82, -44, 45, 67, -120,
-101, -15, -125, 120, -113, 17, -48, -73, 126, -64, -86, -118, -19, 112, -1, -66, -27, -62, 121,
-86, -58, 50, 89, -38, -75, 95, -111, 12, -113, 2, -68, 2, -94, -121, 91, -5, 0, 79, 43, -7,
-18, 79, 35, -38, 47, 1, -45, 83, -50, 102, 32, 55, -96, 15, -122, -69, 45, -27, 91, -62, -30,
46, -95, 22, -72, -97, -1, 14, -122, 28, 127, 61, -126, 121, 9, 68, -120, 49, -60, 90, 3, 43,
68, 54, 34, -10, 28, 21, -24, -54, 22, -113, -12, 82, -2, -17, -9, 127, 8, 116, -92, 0, -70,
-33, 123, 66, 116, -74, -4, 74, -72, -22, -47, 1, -83, -60, -124, 1, 122, -57, -43, 49, 40,
-126, -128, -8, -29, 28, -24, -123, -121, -70, -93, -37, -126, 11, -125, -37, 11, -31, -51,
-124, 116, -128, 8, -25, 109, 75, -12, 7, 8, 10, 117, 124, -128, -128, 29, -26, 101, 21, -128,
87, 8, -39, 23, -128, 127, -127, 74, -55, 74, 112, 127, 4, 55, 44, -92, 123, 34, -93, 47, -21,
-92, 17, 49, -121, 92, 7, -126, -125, 124, -74, 3, -59, 18, -91, 3, -9, 9, 56, 116, 7, -29, 33,
87, -21, -128, -13, 57, 74, 9, -29, -61, -97, -21, -95, -12, -114, 16, 82, 125, -7, 10, -24, 9,
77, -128, -102, -25, 3, -126, 10, 13, -18, 51, 26, 127, -79, 35, 51, 12, -50, -24, 1, -7, 22,
81, 65, 120, -30, -38, 85, 122, -4, -106, -11, 27, 53, 41, 8, -104, -66, -38, -124, 10, 12, 76,
117, -109, 9, 11, 2, -18, 3, 113, -16, -79, -39, -123, -20, -128, 2, 13, -33, -58, 10, 84, -104,
13, 64, 109, 1, 54, -12, 28, 24, 63, -126, 118, -82, 46, -12, -15, 14, -43, 60, 22, -32, -19,
-46, 91, -107, 24, -94, 26, -47, 125, 6, 58, -15, -75, -26, -38, -35, 103, -16, -17, -13, 63,
-2, 45, -45, -73, -23, 70, -87, 51, -17, 53, 76, 14, -18, -31, -14, 103, 8, 21, -28, -33, -20,
-47, 6, 39, 40, -30, 7, -76, 55, 31, -20, -21, -59, 1, 25, -11, 17, 5, -13, -39, 0, -76, 50,
-33, -29, -50, -16, -11, -12, -1, -46, 40, -10, 65, -19, 21, -41, -32, -83, -19, -4, 49, -60,
118, -24, -46, 9, 102, -20, 8, -19, 25, 31, -3, -37, 0, 25, 7, 29, 2, -39, 127, -64, -20, 64,
115, -30, 36, 100, 35, 122, 127, 127, -127, 127, -127, 19, 127, -89, -79, -32, 39, -127, 125,
-80, 126, -127, 26, 8, 98, -8, -57, -90, -50, 126, 61, 127, -126, 40, -106, -68, 104, -125,
-119, 11, 10, -127, 66, -56, -12, -126, -104, 27, 75, 38, -124, -126, -125, 84, -123, -45, -114,
-128, 127, 103, -101, -124, 127, -11, -23, -123, 92, -123, 24, 126, 41, -2, -39, -27, -94, 40,
-112, -48, 127, 58, 14, 38, -75, -64, 73, 117, 100, -119, -11, 6, 32, -126, -14, 35, 121, -10,
54, -60, 89, -3, 69, -25, -20, 43, -86, -34, 24, 27, 7, -81, -99, -23, -16, -26, 13, 35, -97,
80, -29, -13, -121, -12, -65, -94, 70, -89, -126, -95, 88, 33, 96, 29, -90, 69, 114, -78, 65,
90, -47, -47, 89, 1, -12, 3, 8, 30, 5, 2, -30, -1, 6, -7, 10, -4, 46, -27, -40, 22, -6, -17, 45,
24, -9, 23, -14, -63, -26, -12, -57, 27, 25, 55, -76, -47, 21, 34, 33, 26, 17, 14, 6, 9, 26, 25,
-25, -25, -18
};
/** FC1 bias (24). */
static final byte[] INPUT_DENSE_BIAS = {
38, -6, 127, 127, 127, -43, -127, 78, 127, 5, 127, 123, 127, 127, -128, -76, -126, 28, 127, 125,
-30, 127, -89, -20
};
/** GRU1 input weights, [input][gate][output] (24 x 3 x 24). */
static final byte[] HIDDEN_GRU_WEIGHTS = {
-124, 23, -123, -33, -95, -4, 8, -84, 4, 101, -119, 116, -4, 123, 103, -51, 29, -124, -114, -49,
31, 9, 75, -128, 0, -49, 37, -50, 46, -21, -63, -104, 54, 82, 33, 21, 70, 127, -9, -79, -39,
-23, -127, 107, 122, -96, -46, -18, -39, 13, -28, -48, 14, 56, -52, 49, -1, -121, 25, -18, -36,
-52, -57, -30, 54, -124, -26, -47, 10, 39, 12, 2, 9, -127, -128, 102, 21, 11, -64, -71, 89,
-113, -111, 54, 31, 94, 121, -40, 30, 40, -109, 73, -9, 108, -92, 2, -127, 116, 127, 127, -122,
95, 127, -37, -127, 28, 89, 10, 24, -104, -62, -67, -14, 38, 14, -71, 22, -41, 20, -50, 39, 63,
86, 127, -18, 79, 4, -51, 2, 33, 117, -113, -78, 56, -91, 37, 34, -45, -44, -22, 21, -16, 56,
30, -84, -79, 38, -74, 127, 9, -25, 2, 82, 61, 25, -26, 26, 11, 117, -65, 12, -58, 42, -62, -93,
11, 11, 124, -123, 80, -125, 11, -90, 42, 94, 4, -109, -1, 85, -52, 45, -26, -27, 77, -5, 30,
90, 0, 95, -7, 53, 29, -82, 22, -9, 74, 2, -12, -73, 114, 97, -64, 122, -77, 43, 91, 86, 126,
106, 72, 90, -43, 46, 96, -51, 21, 22, 68, 22, 41, 79, 75, -46, -105, 23, -116, 127, -123, 102,
57, 85, 10, -29, 34, 125, 126, 124, 81, -15, 54, 96, -128, 39, -124, 103, 74, 126, 127, -50,
-71, -122, -64, 93, -75, 71, 105, 122, 123, 126, 122, -127, 33, -63, -74, 124, -71, 33, 41, -56,
19, 6, 65, 41, 90, -116, -3, -46, 75, -13, 98, -74, -42, 74, -95, -96, 81, 24, 32, -19, -123,
74, 55, 109, 115, 0, 32, 33, 12, -20, 9, 127, 127, -61, 79, -48, -54, -49, 101, -9, 27, -106,
74, 119, 77, 87, -126, -24, 127, 124, 31, 34, 127, 40, 3, -90, 127, 23, 57, -53, 127, -69, -88,
-33, 127, 19, -46, -9, -125, 13, -126, -113, 127, -41, 46, 106, -62, 3, -10, 111, 49, -34, -24,
-20, -112, 11, 101, -50, -34, 50, 65, -64, -106, 70, -48, 60, 9, -122, -45, 15, -112, -26, -4,
1, 39, 23, 58, -45, -80, 127, 82, 58, 30, -94, -119, 51, -89, 95, -107, 30, 127, 125, 58, -52,
-42, -38, -20, -122, 115, 39, -26, 5, 73, 13, -39, 43, -23, -20, -125, 23, 35, 53, -61, -66, 72,
-20, 33, 8, 35, 4, 7, 18, 19, 16, -45, -50, -71, 31, -29, -41, -27, 10, 14, 27, 9, -23, 98, 6,
-94, 92, 127, -114, 59, -26, -100, -62, -127, -17, -85, -60, 126, -42, -6, 33, -120, -26, -126,
-127, -35, -114, -31, 25, -126, -100, -126, -64, -46, -31, 30, 25, -74, -111, -97, -81, -104,
-114, -19, -9, -116, -69, 22, 30, 59, 8, -51, 16, -97, 18, -4, -89, 80, -50, 3, 36, -67, 56, 69,
-26, 107, -10, 58, -28, -4, -57, -72, -111, 0, -75, -119, 14, -75, -49, -66, -49, 8, -121, 22,
-54, 121, 30, 54, -26, -126, -123, 56, 5, 48, 21, -127, -11, 23, 25, -82, 6, -25, 119, 78, 4,
-104, 27, 61, -48, 37, -13, -52, 50, -50, 44, -1, -22, -43, -59, -78, -67, -32, -26, 9, -3, 40,
16, 19, 3, -9, 20, -6, -37, 28, 39, 17, -19, -10, 1, 6, -59, 74, 47, 3, -119, 0, -128, -107,
-25, -22, -69, -23, -111, -42, -93, -120, 90, -85, -54, -118, 76, -79, 124, 101, -77, -75, -17,
-71, -114, 68, 55, 79, -1, -123, -20, 127, -65, -123, -128, -87, 123, 9, -115, -14, 7, -4, 127,
-79, -115, 125, -28, 89, -83, 49, 89, 119, -69, -5, 12, -49, 60, 57, -24, -99, -110, 76, -83,
125, 73, 81, 11, 8, -45, 1, 83, 13, -70, -2, 97, 112, -97, 53, -9, -94, 124, 44, -49, -24, 52,
76, -110, -70, -114, -12, 72, -4, -114, 43, -43, 81, 102, -84, -27, 62, -40, 52, 58, 124, -35,
-51, -123, -43, 56, -75, -34, -35, -106, 93, -43, 14, -16, 46, 62, -97, 21, 30, -53, 21, -11,
-33, -20, -95, 4, -126, 12, 45, 20, 108, 85, 11, 20, -40, 99, 4, -25, -18, -23, -12, -126, -55,
-20, -44, -51, 91, -127, 127, -44, 7, 127, 78, 38, 125, -6, -94, -103, 73, 126, -126, 18, 59,
-46, 106, 76, 116, -31, 75, -4, 92, 102, 32, -31, 73, 42, -21, -28, 57, 127, -8, -107, 115, 124,
-94, -4, -128, 29, -57, 70, -82, 50, -13, -44, 38, 67, -93, 6, -39, -46, 56, 68, 27, 61, 26, 18,
-72, 127, 22, 18, -31, 127, 61, -65, -38, 1, -67, -1, 8, -73, 46, -116, -94, 58, -49, 71, -40,
-63, -82, -20, -60, 93, 76, 69, -106, 34, -31, 4, -25, 107, -18, 45, 4, -61, 126, 54, -126,
-125, 41, 19, 44, 32, -98, 125, -24, 125, -96, -125, 15, 87, -4, -90, 18, -40, 28, -69, 67, 22,
41, 39, 7, -48, -44, 12, 69, -13, 2, 44, -38, 111, -7, -126, -22, -9, 74, -128, -36, -7, -123,
-15, -79, -91, -37, -127, -122, 104, 30, 7, 98, -37, 111, -116, -47, 127, -45, 118, -111, -123,
-120, -77, -64, -125, 124, 77, 111, 77, 18, -113, 117, -9, 67, -77, 126, 49, -20, -124, 39, 41,
-124, -34, 114, -87, -126, 98, -20, 59, -17, -24, 125, 107, 54, 35, 33, -44, 12, -29, 125, -71,
-28, -63, -114, 28, -17, 121, -36, 127, 89, -122, -49, -18, -48, 17, 24, 19, -64, -128, 13, 86,
45, 13, -49, 55, 84, 48, 80, -39, 99, -127, 70, -33, 30, 50, 126, -65, -117, -13, -20, -24, 127,
115, -72, -104, 63, 126, -42, 57, 17, 46, 21, 119, 110, -100, -60, -112, 62, -33, 28, 26, -22,
-60, -33, -54, 78, 25, 32, -114, 86, 44, 26, 43, 76, 121, 19, 97, -2, -3, -73, -68, 6, -116, 6,
-43, -97, 46, -128, -120, -31, -119, -29, 16, 16, -126, -128, -126, -46, -9, -3, 92, -31, -76,
-126, -3, -107, -12, -23, -69, 5, 51, 27, -42, 23, -70, -128, -29, 22, 29, -126, -55, 50, -71,
-3, 127, 44, -27, -70, -63, -66, -70, 104, 86, 115, 29, -92, 41, -90, 44, -11, -28, 20, -11,
-63, -16, 43, 31, 17, -73, -31, -1, -17, -11, -39, 56, 18, 124, 72, -14, 28, 69, -121, -125, 34,
127, 63, 86, -80, -126, -125, -124, -47, 124, 77, 124, -19, 23, -7, -50, 96, -128, -93, 102,
-53, -36, -87, 119, -125, 92, -126, 118, 102, 72, -2, 125, 10, 97, 124, -125, 125, 71, -20, -47,
-116, -121, -4, -9, -32, 79, -124, -36, 33, -128, -74, 125, 23, 127, -29, -115, -32, 124, -89,
32, -107, 43, -17, 24, 24, 18, 29, -13, -15, -36, 62, -91, 4, -41, 95, 28, -23, 6, 46, 84, 66,
77, 68, -70, -1, -23, -6, 65, 70, -21, 9, 77, -12, 2, -118, 4, 9, -108, 84, 52, 2, 52, 13, -10,
58, -110, 18, 66, -95, -23, 70, 31, -3, 56, 56, -3, -7, 1, -27, -48, -61, 41, -4, 10, -62, 32,
-7, -24, 9, -48, -60, -4, 79, -20, -38, -76, 68, -49, -97, 0, -15, 5, -100, -49, -95, -99, -115,
-9, -40, 10, 104, 13, 56, 127, -27, -109, -94, -118, -102, -44, -85, 52, 127, -4, 14, 62, 121,
-122, -26, -79, -42, -34, 1, 25, -38, -79, -58, -31, -31, -90, -30, -123, 32, -56, 125, 66, 124,
-1, 3, 91, -103, -7, 23, 78, -18, 9, 69, -69, 76, -38, -33, -2, -98, 18, 106, 84, 55, 87, -47,
35, -124, 64, 41, -14, 46, 25, -2, 120, -21, 82, 19, -79, -37, -3, -8, -16, 21, 19, -5, -28,
-112, 39, -6, -30, 53, -69, 53, 46, 127, 123, 78, 20, 28, -7, 73, 72, 17, -40, 41, 111, 57, 32,
-95, 29, 28, -39, -65, 54, -20, -63, 29, -67, 3, 44, -57, -47, 11, 61, -22, -44, 61, 48, -100,
20, 125, 96, -24, -16, 3, -69, -126, 74, -125, 9, 45, -67, -123, -59, -72, 118, 69, 45, 50, -57,
67, 13, -66, -106, 47, 62, 22, -1, -22, -25, -40, -125, 3, 125, 32, 102, -56, -25, -75, -30,
122, 60, -13, 36, -73, 7, -84, 124, 40, -118, 17, -87, -118, -8, 3, -27, 111, -40, 40, -51, 127,
125, -45, -30, -54, 46, 80, -1, -30, 101, -17, 18, 26, 54, 7, -12, 1, -127, 123, -122, -27, -75,
64, 10, 25, -15, -44, 127, -127, 5, -84, -81, -7, 19, -26, 126, 15, 116, -126, 14, -76, 44, 62,
-110, -124, 125, -29, -87, -3, -69, 82, 90, 57, -123, 123, 100, -19, -51, -32, 69, 37, -57,
-128, -124, -72, -13, 51, -7, -45, -73, 5, 99, -26, -117, -96, -109, 4, -31, -12, 0, 31, -42,
-27, 12, -81, 118, 39, 83, 14, 41, -126, 107, -82, 94, -116, -122, -47, -109, -84, -128, -35,
-56, 66, 8, -65, 19, 42, -46, -72, -109, 41, 43, -127, -113, 58, 127, 42, -75, -1, 65, 117, -55,
-113, -123, 124, 43, -96, -115, -19, 68, 15, 94, 3, 75, 0, 34, 9, 42, 110, -48, 92, -76, 99,
-17, 27, 32, 13, 125, 50, -17, 56, 4, 53, 34, -8, 99, 80, -126, -21, -65, -11, -46, 44, -81, -3,
-121, 123, 66, -81, -84, 119, 127, 84, 105, 45, -66, -42, -23, 32, -25, 12, 111, 127, 88, 125,
30, 24, -127, -9, -54, 127, -116, -119, 88, 70, 94, -120, 35, -93, 15, 22, -21, 25, -110, -123,
-45, 8, -109, 125, -122, -86, -126, 8, -14, -120, -45, -45, 69, -125, -122, 6, 81, 86, 125, 95,
54, 77, 54, -123, 126, -85, -117, 56, 11, 0, -61, -91, -12, -2, -113, -3, -15, -122, -63, -91,
10, 84, -111, 125, 93, 21, 62, -78, -116, 13, -57, 28, -124, 126, 110, 12, 15, 95, 15, -19,
-125, -97, 52, -7, 101, 9, 20, -125, -26, -56, 72, 77, 12, -126, 22, -29, 47, 62, 95, 112, 69,
32, 97, -83, -8, -5, 67, -63, -123, 79, 59, 0, -6, -17, 4, -111, -52, 27, 65, 0
};
/** GRU1 recurrent weights, same layout (24 x 3 x 24). */
static final byte[] HIDDEN_GRU_RECURRENT_WEIGHTS = {
65, 83, 35, 56, 24, -34, -28, -2, 125, 19, 42, -9, 124, -53, 24, -87, 11, 35, -81, -35, -125,
-31, 123, -21, 33, -91, 113, -93, 45, -6, 53, 38, -92, 8, -27, 87, 4, 43, 43, 10, -128, -128,
-46, 127, -38, -45, 25, -87, 19, 5, 52, -96, -23, -29, 121, -126, -24, -20, -2, 69, -50, 6, 71,
-81, -125, 90, -94, 1, -38, 36, 89, 17, -60, 71, -48, 18, -15, 44, -18, 59, 11, 114, -51, 32,
110, 1, 4, 109, -24, 127, 27, 60, 88, 24, 45, -59, 75, -36, 8, 57, -32, -25, 13, 126, -89, -61,
-76, 127, 18, -62, -68, 23, -113, 5, 126, 43, -88, 26, -78, 18, 75, 21, 9, -74, 20, 41, 126,
-118, -15, 9, 116, 126, -127, 34, -6, 126, -128, -53, -54, -55, -121, 70, 127, -12, -68, 82,
-25, 104, -126, 126, -21, -26, 124, -75, -127, -120, 13, 61, -64, -108, -63, -65, -44, -35, -61,
-39, 109, -74, 113, -3, 108, -30, 125, 120, 39, 125, -128, -95, -99, 111, 9, 25, 114, -75, -92,
-54, -12, -32, -38, 10, 31, 10, 63, 51, 40, -99, 74, 4, 50, -128, -36, -35, -11, -28, -126, -7,
66, -58, -126, -22, -83, -61, -127, 49, 126, -8, 7, 62, 36, -11, -32, -44, 63, 116, 41, 65,
-127, 126, 63, -30, -96, 74, -92, 127, 38, -18, -128, 68, -5, 101, -4, 85, 58, 79, 0, -58, 8,
119, -70, -1, -79, -68, 114, -28, -90, -6, -112, 2, 127, -8, 10, 55, -59, -126, 127, 125, 80,
72, 35, -54, 95, -124, -124, 79, 23, -46, -61, -127, -100, 99, -77, 8, -87, 5, -2, 49, 85, 7,
-71, 82, 53, -41, 22, -22, -93, -103, 6, 52, -56, 14, -8, -111, 85, 16, 54, 32, -118, -24, 61,
-53, 96, -70, -5, -17, -67, -84, -7, -82, -107, -96, 21, -83, -58, 50, 12, -126, -1, -28, 34,
-126, 115, 17, 91, 1, -127, 72, 11, 126, -81, 6, 96, -8, 77, 15, -6, 63, -27, 20, -123, -109,
85, -79, -17, 126, -92, 2, -61, 20, 14, 17, 121, 123, 30, 57, 120, 127, 57, 42, 117, 98, 67, 39,
-20, -70, 100, 7, 125, 122, 40, 16, -79, 125, 83, 41, -106, -57, 24, 55, 27, -66, -111, -44, -7,
-43, -66, 121, 42, -128, -45, 35, 15, -127, 34, -35, -34, -40, -18, -6, 63, 111, 31, 116, 127,
19, 24, -71, -39, 34, 11, 19, -40, 27, 12, 106, -10, 56, -82, -106, -2, -50, -52, 114, -126,
-34, -43, -68, 10, 76, 57, -118, -128, 37, -104, 76, 125, 3, -76, 127, -29, 84, -94, -15, 55,
125, 79, 127, -57, -125, 104, -68, 126, 126, -77, 51, 45, 33, -109, 115, -11, 1, 95, -121, -5,
-9, -126, -114, 39, 68, -126, -107, -51, -42, 24, -8, 51, -27, -43, 66, -45, 62, -98, -109, 69,
67, 0, -125, -128, 49, 31, 126, -122, 2, -55, -67, -126, -70, -128, -125, -77, 25, 16, -8, -102,
11, -75, 82, 38, -5, 5, 19, 34, 47, -127, -93, 21, 24, -97, -18, 31, 39, 34, -20, 22, 123, 7,
-77, -81, -46, -9, 1, 23, 39, -127, -43, -8, -50, 10, -21, 59, -9, -4, -13, -27, 44, 127, 52,
-47, 70, -43, 52, 101, -49, 27, 45, 49, 33, -125, 55, 114, 20, -1, 76, -24, -96, 105, 24, 126,
75, -21, -105, 13, -42, 40, 126, -30, -39, -95, 125, -63, 11, 6, 125, 125, -14, 5, 42, -61, -4,
49, 88, 6, -107, -28, 19, -29, 47, 126, 6, -46, -89, -18, 91, -20, -6, 118, -21, -22, 39, 115,
11, -42, 54, 73, -55, -77, 62, -27, -59, -99, -12, -127, -40, 56, -3, -124, -91, 71, -111, 6,
-19, 82, -24, -35, 102, -42, 7, -126, -126, -125, 18, 98, -52, 127, 105, -52, 40, -83, 126,
-122, 109, 5, 127, 48, 6, 5, -125, 100, -16, 29, 85, -89, 8, 4, 41, 62, -127, 62, 122, 85, 122,
-107, 8, -125, 93, -127, 127, 102, 19, 19, -66, 41, -42, 114, 127, -48, -117, -29, -6, -73,
-102, -3, -19, 0, 88, 42, 87, -117, -20, 2, 122, 28, 63, 71, 66, 120, 93, 124, -43, 49, 103, 31,
90, -91, -22, -126, 26, -24, -21, 51, -126, 87, -103, -69, -10, -66, -23, 20, 97, 36, 25, -127,
30, -20, -63, 30, 51, -116, 23, 40, -39, 36, -83, -77, -25, -50, 110, 14, 13, -109, 125, -65,
-55, -87, 124, -126, -32, -72, -108, 127, 127, -125, -124, 61, 121, 102, -128, -127, 16, 100,
127, -124, -68, 72, -93, -128, 43, -93, -19, -125, -97, -113, -33, 83, 127, -44, 127, -75, 127,
16, 44, 50, -122, 23, 118, 46, 19, 26, -128, 10, 4, 99, -14, -82, -13, 30, 125, 57, 65, 60, -71,
35, 98, 28, 7, 1, 43, 89, 70, 75, 121, -59, 82, -126, -53, -16, -116, -65, 52, -52, 0, 80, 35,
45, -61, 46, 8, 107, 27, -26, -118, 90, 57, -10, 7, -15, 0, -39, -4, 12, 29, -1, 116, 84, 79,
119, 125, -59, 28, -6, -25, -43, 2, 90, 79, 67, 103, -82, 2, -6, 125, 19, 73, 0, -105, 112, -17,
104, 107, 124, 106, 19, 56, -44, 55, -112, 6, -39, -83, 126, -93, -98, 57, -120, -23, -38, 2,
-31, -48, 106, 127, 127, 69, 16, 110, 71, 104, 62, -12, -22, 42, -37, -94, 34, -1, -32, -12,
-124, -47, -13, 60, -75, -66, 58, -127, -2, 64, 76, -106, 73, -49, -31, 127, 126, 31, 16, 127,
-110, 107, -16, -53, 20, 69, -14, -125, 59, -44, 15, 120, 125, 125, 43, 6, 19, -58, 127, 127,
43, 16, 82, 97, -127, 127, -93, -41, 88, 0, 77, -15, 116, 16, -124, -31, -3, 95, -40, -126, -54,
-126, -83, -8, -59, 6, 67, -29, 4, 124, -10, 112, -28, -8, 85, -21, 45, 84, 6, -8, 11, 72, 32,
84, -62, 77, 2, -36, 75, 31, -50, 116, 126, 119, -88, -55, -14, -37, 126, 40, -108, -6, -6, 57,
64, -28, -76, 30, -117, -93, 31, -92, -44, -64, 94, 58, 65, 114, 41, 47, 71, 42, -26, 99, -126,
57, -5, 74, -19, -113, -1, 67, -21, 126, 1, -3, 33, 60, -82, 37, -48, 89, 114, -38, 127, -114,
35, 58, -5, 21, -46, 121, -123, -43, 127, 115, 123, 122, -101, 126, 127, 81, 52, 89, -127, 102,
42, 117, -9, -2, 125, 127, 110, 96, 120, 66, 70, 124, 55, 84, -38, -58, 119, -127, -16, -79,
123, 18, -127, -50, -38, 120, -85, 1, 7, -56, 108, -77, -2, 21, 37, 1, 13, -105, -69, 28, -87,
33, -104, -51, 126, 41, 3, -121, 28, 71, 58, 86, -8, 127, 94, -55, 125, 40, -19, 127, -33, -87,
-23, 7, -111, -68, 9, 84, -119, 55, -82, 78, -37, -20, -9, -23, 53, -13, 15, -46, 116, 126,
-127, 56, -126, 125, -7, -1, 45, 26, 125, 121, 29, 47, -86, 30, 10, 76, -125, -7, 23, 92, -12,
-39, -18, 92, -97, -8, -85, -41, 49, -50, 123, -37, -126, -30, 14, 79, -49, -65, 9, -36, -38,
-96, 85, -24, -13, 37, -25, -5, -64, -127, 55, -60, -18, -61, -63, 127, 56, 67, 15, 124, 72,
120, 127, 40, -10, 114, 24, -23, 46, 78, -53, 125, 86, 124, 86, 0, 38, 93, 21, 127, 123, 75,
-72, 13, 48, 33, 83, -51, 15, -32, -49, -33, 120, 64, 7, 9, 65, 60, 21, -21, -61, -53, -113, 84,
-97, 101, 37, -114, -27, 41, 73, 126, -10, 59, 61, -15, 70, -13, 82, -4, 69, 56, 94, -91, -50,
92, -74, -48, 53, -7, -107, 127, 28, 30, -26, -21, -61, 77, 82, 64, -91, -125, 122, -104, 127,
123, 122, 123, 76, -126, 127, -6, -80, 7, 40, -66, -65, 54, -2, 23, 96, -64, 74, 2, -53, -12,
-123, 39, 60, -20, 16, -17, -97, 23, -4, -53, -122, 32, -16, -54, -95, 43, 71, -1, -67, -33, 41,
18, 72, 28, -83, 31, -100, -91, -27, 10, -128, -106, 2, 76, -13, 42, 34, 112, -19, 44, 40, -9,
-11, 65, 92, -43, -125, 2, 47, -32, 25, 122, -29, 12, 101, -8, -126, -23, 43, 7, 125, -20, -124,
82, -2, 13, -73, -106, 115, 31, 116, -23, -44, -71, 84, 3, 47, 91, 127, 127, -15, 95, 7, 93, 5,
113, -50, 54, 11, 13, -127, 17, 72, 43, -23, 5, -70, 20, 15, -27, 99, 69, -109, -122, -94, 16,
127, 0, 116, 104, 45, 108, -34, 87, 72, -14, 118, 46, 42, 109, -26, 95, 93, 127, 60, 127, -93,
-54, -122, 34, -105, 56, 55, 103, 125, -71, -50, 95, -72, 127, 107, 21, 73, 126, 61, 127, 127,
24, -62, 90, 73, 90, -46, -78, -124, 72, 123, -42, 50, -107, 17, -32, -62, -89, 124, 1, 80, -2,
117, 119, -65, -127, -95, -121, -52, 103, 66, 75, -3, -62, -127, 127, -74, 124, 79, 49, 40, 105,
-67, -71, -70, 43, 127, 119, -4, 66, 43, 23, 91, -126, 15, 63, -119, 112, 103, 15, -99, 31,
-127, 69, 116, -46, -67, 2, -126, -29, 30, 30, -69, -98, -47, -87, -70, -127, 23, -73, 30, -7,
94, -52, -65, 98, -45, 97, 53, 23, -9, -22, -52, -47, 6, -1, -85, -15, -61, -14, 68, 110, -10,
-121, -25, -35, -15, -94, -123, 27, 75, 48, -66, -56, -44, 93, 109, 67, -36, 24, 70, -126, 8,
-127, 126, 52, 11, -32, 120, -13, -26, -28, -125, 127, 106, -50, 124, 36, -126, -12, 0, -23, 76,
-71, -126, -12, -17, -82, 12, 124, 57, 33, 4, 77, -46, 71, -34, 72, 125, -128, 124, -24, -128,
75, -120, 69, -45, 55, 33, 127, -33, 4, -105, -41, -59, -91, 123, 44, -127, 127, -67, 52, 25,
-125, -65, 100, -25, 123, 6, 11, -123, -92, -33, 126, -17, -4, 29, 33, 127, 96, 3, 87, -48, -18,
-70, 123, 58, -127, -3, -52, -1, -36, -41, 127, 51, -52, -27, 46, -83, 57, 9, 126, 127, 94, 79,
-37, -127, -40, 67, 52, 82, -66, 122, -13, -73, 127, -8, -80, 46, -48, 4, -54
};
/** GRU1 bias, [gate][output] (3 x 24). */
static final byte[] HIDDEN_GRU_BIAS = {
124, 125, -57, -126, 53, 123, 127, -75, 68, 102, -2, 116, 124, 127, 124, 125, 126, 123, -16, 48,
125, 126, 78, 85, 11, 126, -30, -30, -64, -3, -105, -29, -17, 69, 63, 2, -32, -10, -62, 113,
-52, 112, -109, 112, 7, -40, 73, 53, 62, 6, -2, 0, 0, 100, -16, 26, -24, 56, 26, -10, -33, 41,
70, 109, -29, 127, 34, -66, 49, 53, 27, 62
};
/** FC2 weights (24 x 1). */
static final byte[] OUTPUT_DENSE_WEIGHTS = {
127, 127, 127, 127, 127, 20, 127, -126, -126, -54, 14, 125, -126, -126, 127, -125, -126, 127,
-127, -127, -57, -30, 127, 80
};
/** FC2 bias (1). */
static final byte[] OUTPUT_DENSE_BIAS = {
-50
};
private RnnVadWeights() {
}
}

View File

@@ -0,0 +1,281 @@
package com.ts3client.audio.vad;
import static com.ts3client.audio.vad.VadConstants.CEPSTRAL_HISTORY;
import static com.ts3client.audio.vad.VadConstants.FRAME_20MS_24K;
import static com.ts3client.audio.vad.VadConstants.NUM_BANDS;
import static com.ts3client.audio.vad.VadConstants.NUM_LOWER_BANDS;
import static com.ts3client.audio.vad.VadConstants.OPUS_BANDS_24K;
/**
* Band-wise spectral features for {@code rnn_vad}, ported from WebRTC's
* {@code rnn_vad/spectral_features.cc} and {@code spectral_features_internal.cc}.
*
* <p>A 20&nbsp;ms Vorbis-windowed FFT is folded into 22 Opus-scale bands, log-compressed
* and DCT'd into a cepstrum. The network is fed the higher cepstral coefficients, the
* running average and first two derivatives of the lower ones, the cepstrum of the
* band-wise correlation between the current frame and the frame one pitch period earlier,
* and a measure of how much the cepstrum has been varying.
*/
final class SpectralFeatures {
/** Total band energy below which the frame is treated as silence. */
private static final float SILENCE_THRESHOLD = 0.04f;
/** FFT bins per Opus band at 24 kHz for a 20 ms frame. */
private static final int[] OPUS_BAND_SIZES =
{4, 4, 4, 4, 4, 4, 4, 4, 8, 8, 8, 8, 16, 16, 16, 24, 24, 32, 48};
/** sqrt(2 / NUM_BANDS), applied after the DCT. */
private static final float DCT_SCALING_FACTOR = 0.301511345f;
private final RealFft fft = new RealFft(FRAME_20MS_24K);
private final float[] halfWindow = new float[FRAME_20MS_24K / 2];
private final float[] bandWeights = new float[FRAME_20MS_24K / 2];
private final float[] dctTable = new float[NUM_BANDS * NUM_BANDS];
private final float[] windowed = new float[FRAME_20MS_24K];
private final float[] referenceFft = new float[FRAME_20MS_24K];
private final float[] laggedFft = new float[FRAME_20MS_24K];
private final float[] referenceBandsEnergy = new float[OPUS_BANDS_24K];
private final float[] laggedBandsEnergy = new float[OPUS_BANDS_24K];
private final float[] bandsCrossCorr = new float[OPUS_BANDS_24K];
private final float[] logBandsEnergy = new float[NUM_BANDS];
private final float[] cepstrum = new float[NUM_BANDS];
private final float[] crossCorrCepstrum = new float[NUM_LOWER_BANDS];
/** Ring buffer of the last {@link VadConstants#CEPSTRAL_HISTORY} cepstra. */
private final float[][] cepstralHistory = new float[CEPSTRAL_HISTORY][NUM_BANDS];
private int cepstralTail;
/**
* Upper-right triangle of pairwise cepstral distances, stored as a square matrix so a
* new frame can be shifted in with a single array copy.
*/
private final float[] cepstralDiffs = new float[(CEPSTRAL_HISTORY - 1) * (CEPSTRAL_HISTORY - 1)];
private final float[] newDistances = new float[CEPSTRAL_HISTORY - 1];
SpectralFeatures() {
int halfSize = FRAME_20MS_24K / 2;
float scaling = 1.0f / FRAME_20MS_24K;
for (int i = 0; i < halfSize; i++) {
double inner = Math.sin(0.5 * Math.PI * (i + 0.5) / halfSize);
halfWindow[i] = (float) (scaling * Math.sin(0.5 * Math.PI * inner * inner));
}
// Each band overlaps its neighbour with a triangular response; the weight is the
// fractional position of the bin within its band.
int bin = 0;
for (int bandSize : OPUS_BAND_SIZES) {
for (int j = 0; j < bandSize; j++) {
bandWeights[bin++] = (float) j / bandSize;
}
}
double k = Math.sqrt(0.5);
for (int i = 0; i < NUM_BANDS; i++) {
for (int j = 0; j < NUM_BANDS; j++) {
dctTable[i * NUM_BANDS + j] = (float) Math.cos((i + 0.5) * j * Math.PI / NUM_BANDS);
}
dctTable[i * NUM_BANDS] *= (float) k;
}
}
void reset() {
for (float[] row : cepstralHistory) {
java.util.Arrays.fill(row, 0.0f);
}
cepstralTail = 0;
java.util.Arrays.fill(cepstralDiffs, 0.0f);
}
/**
* Writes the spectral part of the feature vector and returns {@code true} if the
* reference frame is silence, in which case the vector is only partially written.
*
* @param reference buffer holding the most recent 20 ms at 24 kHz
* @param referenceOffset offset of that frame within {@code reference}
* @param lagged buffer holding the 20 ms one pitch period earlier
* @param laggedOffset offset of that frame within {@code lagged}
*/
boolean checkSilenceComputeFeatures(float[] reference, int referenceOffset,
float[] lagged, int laggedOffset,
float[] featureVector) {
forwardFft(reference, referenceOffset, referenceFft);
crossCorrelation(referenceFft, referenceFft, referenceBandsEnergy);
float totalEnergy = 0;
for (float e : referenceBandsEnergy) {
totalEnergy += e;
}
if (totalEnergy < SILENCE_THRESHOLD) {
return true;
}
forwardFft(lagged, laggedOffset, laggedFft);
crossCorrelation(laggedFft, laggedFft, laggedBandsEnergy);
smoothedLogMagnitudeSpectrum(referenceBandsEnergy, logBandsEnergy);
dct(logBandsEnergy, NUM_BANDS, cepstrum, NUM_BANDS);
// Ad-hoc correction terms for the first two cepstral coefficients.
cepstrum[0] -= 12.0f;
cepstrum[1] -= 4.0f;
pushCepstrum(cepstrum);
// Higher bands go in as-is; the lower ones are summarised over time instead.
System.arraycopy(cepstrum, NUM_LOWER_BANDS, featureVector, NUM_LOWER_BANDS,
NUM_BANDS - NUM_LOWER_BANDS);
computeAverageAndDerivatives(featureVector);
computeNormalizedCepstralCorrelation(featureVector);
featureVector[VadConstants.FEATURE_VECTOR_SIZE - 1] = computeVariability();
return false;
}
/** Windows a 20 ms frame and transforms it, zeroing the unused Nyquist coefficient. */
private void forwardFft(float[] frame, int offset, float[] out) {
int halfSize = FRAME_20MS_24K / 2;
for (int i = 0, j = FRAME_20MS_24K - 1; i < halfSize; i++, j--) {
windowed[i] = frame[offset + i] * halfWindow[i];
windowed[j] = frame[offset + j] * halfWindow[i];
}
fft.forward(windowed, out);
out[1] = 0.0f;
}
/**
* Folds the interleaved spectra into Opus bands. Each bin contributes to the band it
* sits in and to the next one, split by the triangular weight.
*/
private void crossCorrelation(float[] x, float[] y, float[] out) {
int k = 0;
out[0] = 0.0f;
for (int i = 0; i < OPUS_BANDS_24K - 1; i++) {
out[i + 1] = 0.0f;
for (int j = 0; j < OPUS_BAND_SIZES[i]; j++) {
float v = x[2 * k] * y[2 * k] + x[2 * k + 1] * y[2 * k + 1];
float tmp = bandWeights[k] * v;
out[i] += v - tmp;
out[i + 1] += tmp;
k++;
}
}
out[0] *= 2.0f; // The first band only receives half a triangle.
}
/** Log magnitude with smoothing over frequency and a decaying floor. */
private static void smoothedLogMagnitudeSpectrum(float[] bandsEnergy, float[] out) {
final float logOneByHundred = -2.0f;
float logMax = logOneByHundred;
float follow = logOneByHundred;
for (int i = 0; i < out.length; i++) {
float x = (i < bandsEnergy.length)
? (float) Math.log10(0.01f + bandsEnergy[i])
: logOneByHundred;
x = Math.max(logMax - 7.0f, Math.max(follow - 1.5f, x));
logMax = Math.max(logMax, x);
follow = Math.max(follow - 1.5f, x);
out[i] = x;
}
}
/** DCT-II of the first {@code inSize} values of {@code in}, truncated to {@code outSize}. */
private void dct(float[] in, int inSize, float[] out, int outSize) {
for (int i = 0; i < outSize; i++) {
float sum = 0;
for (int j = 0; j < inSize; j++) {
sum += in[j] * dctTable[j * NUM_BANDS + i];
}
out[i] = sum * DCT_SCALING_FACTOR;
}
}
private void pushCepstrum(float[] newCepstrum) {
// Distance from the new cepstrum to each older one, nearest first.
for (int i = 0; i < CEPSTRAL_HISTORY - 1; i++) {
float[] old = cepstralHistoryAt(i);
float sum = 0;
for (int k = 0; k < NUM_BANDS; k++) {
float c = newCepstrum[k] - old[k];
sum += c * c;
}
newDistances[i] = sum;
}
System.arraycopy(newCepstrum, 0, cepstralHistory[cepstralTail], 0, NUM_BANDS);
cepstralTail = (cepstralTail + 1) % CEPSTRAL_HISTORY;
pushCepstralDistances();
}
/** Returns the cepstrum pushed {@code delay} frames ago (0 = most recent). */
private float[] cepstralHistoryAt(int delay) {
int offset = cepstralTail - 1 - delay;
if (offset < 0) {
offset += CEPSTRAL_HISTORY;
}
return cepstralHistory[offset];
}
/** Shifts the distance matrix up-left by one and writes the new column. */
private void pushCepstralDistances() {
final int s = CEPSTRAL_HISTORY;
System.arraycopy(cepstralDiffs, s, cepstralDiffs, 0, cepstralDiffs.length - s);
for (int i = 0; i < newDistances.length; i++) {
cepstralDiffs[(s - 1 - i) * (s - 1) - 1] = newDistances[i];
}
}
private float cepstralDistance(int delay1, int delay2) {
int row = CEPSTRAL_HISTORY - 1 - delay1;
int col = CEPSTRAL_HISTORY - 1 - delay2;
if (row > col) { // Only the upper-right triangle is stored.
int tmp = row;
row = col;
col = tmp;
}
return cepstralDiffs[row * (CEPSTRAL_HISTORY - 1) + (col - 1)];
}
private void computeAverageAndDerivatives(float[] featureVector) {
float[] curr = cepstralHistoryAt(0);
float[] prev1 = cepstralHistoryAt(1);
float[] prev2 = cepstralHistoryAt(2);
for (int i = 0; i < NUM_LOWER_BANDS; i++) {
featureVector[i] = curr[i] + prev1[i] + prev2[i]; // kernel [1, 1, 1]
featureVector[NUM_BANDS + i] = curr[i] - prev2[i]; // kernel [1, 0, -1]
featureVector[NUM_BANDS + NUM_LOWER_BANDS + i] =
curr[i] - 2 * prev1[i] + prev2[i]; // kernel [1, -2, 1]
}
}
/** Band correlation between the current frame and the one a pitch period earlier. */
private void computeNormalizedCepstralCorrelation(float[] featureVector) {
crossCorrelation(referenceFft, laggedFft, bandsCrossCorr);
for (int i = 0; i < bandsCrossCorr.length; i++) {
bandsCrossCorr[i] /= (float) Math.sqrt(
0.001f + referenceBandsEnergy[i] * laggedBandsEnergy[i]);
}
dct(bandsCrossCorr, OPUS_BANDS_24K, crossCorrCepstrum, NUM_LOWER_BANDS);
// Ad-hoc correction terms, as for the frame cepstrum.
crossCorrCepstrum[0] -= 1.3f;
crossCorrCepstrum[1] -= 0.9f;
System.arraycopy(crossCorrCepstrum, 0, featureVector,
NUM_BANDS + 2 * NUM_LOWER_BANDS, NUM_LOWER_BANDS);
}
/**
* How far the current cepstrum sits from its nearest neighbour in the recent history,
* averaged over the history. Steady tones score low, speech scores high.
*/
private float computeVariability() {
float variability = 0;
for (int delay1 = 0; delay1 < CEPSTRAL_HISTORY; delay1++) {
float minDist = Float.MAX_VALUE;
for (int delay2 = 0; delay2 < CEPSTRAL_HISTORY; delay2++) {
if (delay1 == delay2) {
continue; // The distance would be 0.
}
minDist = Math.min(minDist, cepstralDistance(delay1, delay2));
}
variability += minDist;
}
// Normalised against training-set statistics.
return variability / CEPSTRAL_HISTORY - 2.1f;
}
}

View File

@@ -0,0 +1,54 @@
package com.ts3client.audio.vad;
/**
* Sizes and pitch-search bounds of WebRTC's {@code rnn_vad}, mirroring
* {@code modules/audio_processing/agc2/rnn_vad/common.h}.
*
* <p>The detector analyses 10&nbsp;ms frames at 24&nbsp;kHz. Spectral analysis uses the
* most recent 20&nbsp;ms; pitch search additionally needs one maximum pitch period of
* history, hence the 864-sample buffer.
*/
final class VadConstants {
static final int SAMPLE_RATE_24K = 24_000;
static final int FRAME_10MS_24K = SAMPLE_RATE_24K / 100; // 240
static final int FRAME_20MS_24K = FRAME_10MS_24K * 2; // 480
/** Pitch range at 24 kHz: 0.00125 s to 0.016 s. */
static final int MIN_PITCH_24K = SAMPLE_RATE_24K / 800; // 30
static final int MAX_PITCH_24K = 384; // 24000 / 62.5
static final int BUF_SIZE_24K = MAX_PITCH_24K + FRAME_20MS_24K; // 864
/**
* The coarse search skips very short periods; a refinement step recovers them, so the
* initial pass starts three minimum periods in.
*/
static final int INITIAL_MIN_PITCH_24K = 3 * MIN_PITCH_24K; // 90
static final int INITIAL_NUM_LAGS_24K = MAX_PITCH_24K - INITIAL_MIN_PITCH_24K; // 294
static final int REFINE_NUM_LAGS_24K = MAX_PITCH_24K + 1; // 385
// 12 kHz analysis, used for the coarse pitch search.
static final int FRAME_20MS_12K = 240;
static final int BUF_SIZE_12K = BUF_SIZE_24K / 2; // 432
static final int INITIAL_MIN_PITCH_12K = INITIAL_MIN_PITCH_24K / 2; // 45
static final int MAX_PITCH_12K = MAX_PITCH_24K / 2; // 192
static final int NUM_LAGS_12K = MAX_PITCH_12K - INITIAL_MIN_PITCH_12K; // 147
// The pitch period fed to the network is expressed at 48 kHz.
static final int MIN_PITCH_48K = MIN_PITCH_24K * 2; // 60
static final int MAX_PITCH_48K = MAX_PITCH_24K * 2; // 768
// Spectral features.
static final int NUM_BANDS = 22;
static final int NUM_LOWER_BANDS = 6;
static final int CEPSTRAL_HISTORY = 8;
/** Opus bands that fall below the 24 kHz Nyquist frequency. */
static final int OPUS_BANDS_24K = 20;
static final int NUM_LPC_COEFFICIENTS = 5;
static final int FEATURE_VECTOR_SIZE = 42;
private VadConstants() {
}
}

View File

@@ -0,0 +1,89 @@
package com.ts3client.config;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
/**
* Persistent list of away-message presets, stored alongside the settings file.
* Frontend-agnostic: no UI dependencies.
*/
public final class AwayMessages {
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
private static final File FILE = new File(DIR, "away.properties");
private final List<String> entries = new ArrayList<>();
public List<String> all() {
return entries;
}
public void add(String message) {
entries.add(message == null ? "" : message);
}
public void set(int index, String message) {
if (index >= 0 && index < entries.size()) entries.set(index, message == null ? "" : message);
}
public void remove(int index) {
if (index >= 0 && index < entries.size()) entries.remove(index);
}
public static AwayMessages load() {
AwayMessages m = new AwayMessages();
if (!FILE.isFile()) return m.withDefaults();
Properties p = new Properties();
try (FileInputStream in = new FileInputStream(FILE)) {
p.load(in);
} catch (Exception e) {
return m.withDefaults();
}
int count = parseInt(p.getProperty("count"), 0);
for (int i = 0; i < count; i++) {
String text = p.getProperty("message." + i);
if (text != null && !text.isBlank()) m.entries.add(text);
}
return m;
}
public void save() {
Properties p = new Properties();
p.setProperty("count", Integer.toString(entries.size()));
for (int i = 0; i < entries.size(); i++) {
p.setProperty("message." + i, entries.get(i));
}
try {
if (!DIR.isDirectory()) {
//noinspection ResultOfMethodCallIgnored
DIR.mkdirs();
}
try (FileOutputStream out = new FileOutputStream(FILE)) {
p.store(out, "TS3J client away messages");
}
} catch (Exception ignored) {
}
}
/** First run: seed the presets a fresh client offers. */
private AwayMessages withDefaults() {
entries.add("Away from keyboard");
entries.add("Be right back");
entries.add("Lunch");
entries.add("Busy");
return this;
}
private static int parseInt(String v, int def) {
if (v == null) return def;
try {
return Integer.parseInt(v.trim());
} catch (NumberFormatException e) {
return def;
}
}
}

View File

@@ -0,0 +1,88 @@
package com.ts3client.config;
import java.util.List;
/**
* The version, platform and signature a client reports to the server in
* {@code clientinit}. Other clients see the version and platform in the info
* panel ("3.6.2 on Windows").
*
* <p>The signature is TeamSpeak's, over the version and platform strings
* together, so the three only work as a set. Servers answer "client is modified"
* for a version they do not accept — an invented one, or one older than their
* configured minimum — which is why {@link #PRESETS} holds genuine triples taken
* from released clients, newest first per platform.
*
* @param label human-readable name, e.g. "3.6.2 on Windows"
* @param version {@code client_version}, e.g. "3.6.2 [Build: 1695203293]"
* @param platform {@code client_platform}, e.g. "Windows"
* @param sign {@code client_version_sign}, base64
*/
public record ClientVersion(String label, String version, String platform, String sign) {
/** Platform names the official clients report. */
public static final List<String> PLATFORMS =
List.of("Windows", "Linux", "OS X", "Android", "iOS");
/**
* Signed triples from released TeamSpeak clients. "3.?.?" is the version the
* SDK-based clients report; it is signed for every platform and is what this
* client uses by default.
*/
public static final List<ClientVersion> PRESETS = List.of(
new ClientVersion("3.?.? on Windows", "3.?.? [Build: 5680278000]", "Windows",
"DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOoBzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg=="),
new ClientVersion("3.6.3 on Windows", "3.6.3 [Build: 1701166057]", "Windows",
"ML3yE6pV0QGyhc8o7k1bgqDSj+5gZIQFMjBNhWJ6L0FtJwniBq8l1wivx3DzgVVCvyRbzfg8MsdV/rjl5sHNCw=="),
new ClientVersion("3.6.2 on Windows", "3.6.2 [Build: 1695203293]", "Windows",
"4BdaZpdgUSMCuIs8qcloJPNxNlJ4o7QKnxMCRO60mSOTtJZyKjOrGLAmeAEtLIJjcjmdSpycMbQOIV92K2vXAw=="),
new ClientVersion("3.6.1 on Windows", "3.6.1 [Build: 1690872913]", "Windows",
"+YmPaYUBL4F9+TgxyFhlT8qIi1Ym+So4NNMcuohla/u4kfJCGabZ1vDF4cIOAghIqWfO+bQ0OkORZrCBdpi2Cw=="),
new ClientVersion("3.6.0 on Windows", "3.6.0 [Build: 1686749764]", "Windows",
"gfzG6CfwNYeqrjT68s7ahPd24A/XS0P8LLAB7hrE54MnrlO12RiflD2w69VZ7EYlqnd9GAiVzdGJHCmCFWtlAQ=="),
new ClientVersion("3.?.? on Linux", "3.?.? [Build: 5680278000]", "Linux",
"Hjd+N58Gv3ENhoKmGYy2bNRBsNNgm5kpiaQWxOj5HN2DXttG6REjymSwJtpJ8muC2gSwRuZi0R+8Laan5ts5CQ=="),
new ClientVersion("3.6.2 on Linux", "3.6.2 [Build: 1695203293]", "Linux",
"p4iF1jZ3ZOz9MEkKSZ2bvnFtm9WmUcQy9mAP//erFE4PF1sB6K1CSANrr+3X4B0aZR0u+K2pjnv8kiKsWKQCBQ=="),
new ClientVersion("3.6.1 on Linux", "3.6.1 [Build: 1690193193]", "Linux",
"UAcE5h5FgPcdChWTtiO816HX8f2qdoDcDEw6crbJ96Yo7rp1Bt+qx/rkKK9bzlVWMhOd4fI7pr++1XaPkEBnAQ=="),
new ClientVersion("3.6.0 on Linux", "3.6.0 [Build: 1686749764]", "Linux",
"TccRBHwn5LSA0lfpa2B42QmfCOiG+MqqyhlOqy5RcuRQNUGPD0e47lUz+Z/8+Gsdc1TPG4+vYlRl8+v2v+26Aw=="),
new ClientVersion("3.5.8 on Linux", "3.5.8 [Build: 1644595825]", "Linux",
"CK+cWJQESZgUdw1AFHINMbwiLjFGSiupour0Dfj/j1/xwx4L3nW3G+alYtiQw/wQwlWm873HS6PvXUBarz/tCw=="),
new ClientVersion("3.?.? on OS X", "3.?.? [Build: 5680278000]", "OS X",
"SttEnjoWE8jqIM6BOHSfiZP9DGjW0EP/ajU4bdKqgGMV4aYq/kzwVA9gxbmdIzV4lbaokvXBqrRjfBHrTVh8Cg=="),
new ClientVersion("3.6.2 on OS X", "3.6.2 [Build: 1695203293]", "OS X",
"SOCkLcsxINIGVc+3OQ5Vrmv2BVNNFA9LX5Jj9tqbIiYv2HU6Z5E8AL5+v5G2LDN0F+U55+uW43ezUoYAr1WuBA=="),
new ClientVersion("3.6.1 on OS X", "3.6.1 [Build: 1690193193]", "OS X",
"P+KOw42V8K0nN5FbGItCaJzrDuWcRgfE3MRzrtbYAhRMTXHzRxvwLkoLM7+vKTW135NtWZlKbZuDAvxgwhSLBg=="),
new ClientVersion("3.6.0 on OS X", "3.6.0 [Build: 1686749764]", "OS X",
"g8H7Op8eCrn0wHutuD/SlmUcZPmkSlFUBhvZpSgm9kuQl4dapzKJq2anI5jDwRs/ooBm/wrIlSiWI6HXuOPZDA=="),
new ClientVersion("3.?.? on Android", "3.?.? [Build: 5680278000]", "Android",
"AWb948BY32Z7bpIyoAlQguSmxOGcmjESPceQe1DpW5IZ4+AW1KfTk2VUIYNfUPsxReDJMCtlhVKslzhR2lf0AA=="),
new ClientVersion("3.8.7 on Android", "3.8.7 [Build: 1785186501]", "Android",
"mbt0WPhXJ+YKJUix5b6t+ZOHTCfvRqhhhrpcS5koM9CUL0BAFQZRG5Di5rJrqipZdk+gEUtpAX9I5nkuL7V9Dw=="),
new ClientVersion("3.8.6 on Android", "3.8.6 [Build: 1774892004]", "Android",
"Hh0oEZupsq8f0BuUHhMKdkXsMmgZZ03dJL8qAwnlH2JAHuefaAZFS/buiH2MLwgdRkZ81ffYk5YOTfc/bJczDw=="),
new ClientVersion("3.?.? on iOS", "3.?.? [Build: 5680278000]", "iOS",
"XrAf+Buq6Eb0ehEW/niFp06YX+nGGOS0Ke4MoUBzn+cX9q6G5C0A/d5XtgcNMe8r9jJgV/adIYVpsGS3pVlSAA=="),
new ClientVersion("3.8.2 on iOS", "3.8.2 [Build: 1764028963]", "iOS",
"Bc57xjumaioWkouTRqoS6XbSIXysrPH+gxynXgL4/nYr38l4OocUTpQaMMHevmgk/FZxJWFNEhU3Hpg5+M+MAA=="),
new ClientVersion("3.8.1 on iOS", "3.8.1 [Build: 1763403218]", "iOS",
"GM/7isPK6LDKTN3Hdelenbr99dnPyWTccLCOCFKK41321XMsdG02putu+NskCLddlDPu2CPYJ4YABRS2pJ88Ag=="));
/** The triple used when the user has not chosen one; matches the library's built-in. */
public static final ClientVersion DEFAULT = PRESETS.get(0);
/** The preset carrying exactly these three values, or null when the user typed their own. */
public static ClientVersion match(String version, String platform, String sign) {
return PRESETS.stream()
.filter(v -> v.version.equals(version) && v.platform.equals(platform) && v.sign.equals(sign))
.findFirst()
.orElse(null);
}
}

View File

@@ -0,0 +1,60 @@
package com.ts3client.config;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* Locates the folders add-ons (sound packs, icon packs) are loaded from: the
* client's own folder, a folder the user configured, and the matching folder of
* an installed TeamSpeak 3 client, whose add-ons are usable as they are.
*/
public final class InstallDirs {
/** Overrides the TeamSpeak install location the add-ons are borrowed from. */
private static final String CLIENT_DIR_PROPERTY = "ts3.client.dir";
private static final String CLIENT_DIR_ENV = "TS3_CLIENT_DIR";
private InstallDirs() {
}
/**
* @param subfolder the add-on folder inside a TeamSpeak installation ({@code sound}, {@code gfx})
* @param userDirectory the client's own add-on folder (may not exist yet)
* @param extraDirectory an additional folder chosen by the user, or {@code null}
* @return the folders that exist, most specific first
*/
public static List<File> searchRoots(String subfolder, File userDirectory, String extraDirectory) {
List<File> roots = new ArrayList<>();
if (userDirectory != null) roots.add(userDirectory);
if (extraDirectory != null && !extraDirectory.isBlank()) roots.add(new File(extraDirectory.trim()));
String configured = System.getProperty(CLIENT_DIR_PROPERTY, System.getenv(CLIENT_DIR_ENV));
if (configured != null && !configured.isBlank()) roots.add(new File(configured.trim(), subfolder));
String home = System.getProperty("user.home", ".");
for (String candidate : new String[]{
"TeamSpeak3-Client-linux_amd64", "TeamSpeak3-Client-linux_x86",
".local/share/TeamSpeak3-Client-linux_amd64", "Applications/TeamSpeak3-Client-linux_amd64"}) {
roots.add(new File(new File(home, candidate), subfolder));
}
for (String candidate : new String[]{
"/opt/teamspeak3-client", "/opt/teamspeak3", "/usr/share/teamspeak3",
"/usr/share/teamspeak3-client"}) {
roots.add(new File(candidate, subfolder));
}
String programFiles = System.getenv("ProgramFiles");
if (programFiles != null) {
roots.add(new File(programFiles, "TeamSpeak 3 Client" + File.separator + subfolder));
}
// Running from a checkout that has the official client unpacked next to it.
roots.add(new File("TeamSpeak3-Client-linux_amd64/" + subfolder));
roots.add(new File("../TeamSpeak3-Client-linux_amd64/" + subfolder));
List<File> existing = new ArrayList<>();
for (File root : roots) {
if (root.isDirectory()) existing.add(root);
}
return existing;
}
}

View File

@@ -27,6 +27,12 @@ public final class Settings {
CONTINUOUS CONTINUOUS
} }
/** Which look-and-feel variant the window is painted with. */
public enum Appearance {
LIGHT,
DARK
}
/** Voice-activation strategy, mirroring the TS3 client's VAD modes. */ /** Voice-activation strategy, mirroring the TS3 client's VAD modes. */
public enum VadMode { public enum VadMode {
/** Speech-probability detector only. */ /** Speech-probability detector only. */
@@ -51,6 +57,16 @@ public final class Settings {
/** Id of the identity used when a server doesn't select one of its own. */ /** Id of the identity used when a server doesn't select one of its own. */
public String defaultIdentityId = ""; public String defaultIdentityId = "";
// ---- reported client version ----
/** Report {@link #clientVersion}/{@link #clientPlatform} instead of the built-in ones. */
public boolean customVersion = false;
/** Version string other clients see, e.g. "3.5.6 [Build: 1603963200]". */
public String clientVersion = ClientVersion.DEFAULT.version();
/** Operating system other clients see, e.g. "Windows". */
public String clientPlatform = ClientVersion.DEFAULT.platform();
/** TeamSpeak's signature over the version and platform; servers may verify it. */
public String clientVersionSign = ClientVersion.DEFAULT.sign();
// ---- audio devices (mixer names; empty = system default) ---- // ---- audio devices (mixer names; empty = system default) ----
public String inputDevice = ""; public String inputDevice = "";
public String outputDevice = ""; public String outputDevice = "";
@@ -59,14 +75,18 @@ public final class Settings {
public InputMode inputMode = InputMode.VOICE_ACTIVATION; public InputMode inputMode = InputMode.VOICE_ACTIVATION;
/** Voice-activation strategy used when {@link #inputMode} is VOICE_ACTIVATION. */ /** Voice-activation strategy used when {@link #inputMode} is VOICE_ACTIVATION. */
public VadMode vadMode = VadMode.HYBRID; public VadMode vadMode = VadMode.HYBRID;
/** VAD threshold in dBFS (RMS). Typical range -60 (sensitive) .. -10 (loud). */ /**
public double vadThresholdDb = -45.0; * Volume-gate threshold on {@link com.ts3client.audio.InputLevel}'s scale, the same one
/** Speech-probability threshold (0..1) for Automatic/Hybrid modes. */ * the TS3 client's slider uses: -50 (most sensitive) .. +50, defaulting to -40.
*/
public double vadThresholdDb = -40.0;
/**
* Speech-probability threshold (0..1) for Automatic/Hybrid modes. TS3 calls this
* {@code vad_likelihood} and also defaults it to 0.5.
*/
public double speechThreshold = 0.5; public double speechThreshold = 0.5;
/** Keep voice activation running while in push-to-talk mode. */ /** Keep voice activation running while in push-to-talk mode. */
public boolean vadOverPtt = false; public boolean vadOverPtt = false;
/** Push-to-talk key as an AWT virtual-key code; the frontend interprets it. Default: Ctrl (VK_CONTROL). */
public int pushToTalkKey = 17;
/** Opus target bitrate in bits/sec. */ /** Opus target bitrate in bits/sec. */
public int bitrate = 48000; public int bitrate = 48000;
/** Opus complexity 0..10. */ /** Opus complexity 0..10. */
@@ -81,6 +101,12 @@ public final class Settings {
public boolean music = false; public boolean music = false;
/** Master playback gain, 0..1 (may exceed 1 for boost up to 2). */ /** Master playback gain, 0..1 (may exceed 1 for boost up to 2). */
public double outputVolume = 1.0; public double outputVolume = 1.0;
/**
* Global multiplier on top of {@link #outputVolume} and {@link #soundVolume},
* controlled from the toolbar's Master Volume slider so both can be ridden
* with one control. 0..2, same boost headroom as the other volumes.
*/
public double masterVolume = 1.0;
/** Microphone input gain multiplier applied before VAD/encode. */ /** Microphone input gain multiplier applied before VAD/encode. */
public double inputVolume = 1.0; public double inputVolume = 1.0;
/** Remove steady background noise from the microphone (spectral denoise). */ /** Remove steady background noise from the microphone (spectral denoise). */
@@ -99,6 +125,21 @@ public final class Settings {
public double soundVolume = 1.0; public double soundVolume = 1.0;
/** Extra folder to look for sound packs in, on top of the well-known locations. */ /** Extra folder to look for sound packs in, on top of the well-known locations. */
public String soundPackDir = ""; public String soundPackDir = "";
// ---- icons ----
/** File name of the active icon pack; empty uses the built-in icons. */
public String iconPack = "default_colored_2014.zip";
/** Extra folder to look for icon packs in, on top of the well-known locations. */
public String iconPackDir = "";
/** Look-and-feel variant; see {@link Appearance}. */
public Appearance appearance = Appearance.LIGHT;
// ---- window chrome ----
/** Whether the status bar at the bottom of the window is shown. */
public boolean showStatusBar = true;
/** Whether the Master Volume slider is shown on the toolbar. */
public boolean showMasterVolumeSlider = true;
/** Which actions make a sound, and which ones are important enough to survive muting. */ /** Which actions make a sound, and which ones are important enough to survive muting. */
public final NotificationSettings notifications = new NotificationSettings(); public final NotificationSettings notifications = new NotificationSettings();
@@ -135,6 +176,16 @@ public final class Settings {
return new File(identityFile); return new File(identityFile);
} }
/** {@link #outputVolume} scaled by the Master Volume slider. */
public double effectiveOutputVolume() {
return outputVolume * masterVolume;
}
/** {@link #soundVolume} scaled by the Master Volume slider. */
public double effectiveSoundVolume() {
return soundVolume * masterVolume;
}
/** The directory holding all persistent client state (settings, identities, caches). */ /** The directory holding all persistent client state (settings, identities, caches). */
public static File configDir() { public static File configDir() {
return DIR; return DIR;
@@ -146,14 +197,18 @@ public final class Settings {
serverPassword = props.getProperty("serverPassword", serverPassword); serverPassword = props.getProperty("serverPassword", serverPassword);
identityFile = props.getProperty("identityFile", identityFile); identityFile = props.getProperty("identityFile", identityFile);
defaultIdentityId = props.getProperty("defaultIdentityId", defaultIdentityId); defaultIdentityId = props.getProperty("defaultIdentityId", defaultIdentityId);
customVersion = parseB(props.getProperty("customVersion"), customVersion);
clientVersion = props.getProperty("clientVersion", clientVersion);
clientPlatform = props.getProperty("clientPlatform", clientPlatform);
clientVersionSign = props.getProperty("clientVersionSign", clientVersionSign);
inputDevice = props.getProperty("inputDevice", inputDevice); inputDevice = props.getProperty("inputDevice", inputDevice);
outputDevice = props.getProperty("outputDevice", outputDevice); outputDevice = props.getProperty("outputDevice", outputDevice);
inputMode = parseMode(props.getProperty("inputMode"), inputMode); inputMode = parseMode(props.getProperty("inputMode"), inputMode);
vadMode = parseVadMode(props.getProperty("vadMode"), vadMode); vadMode = parseVadMode(props.getProperty("vadMode"), vadMode);
vadThresholdDb = parseD(props.getProperty("vadThresholdDb"), vadThresholdDb); vadThresholdDb = parseD(props.getProperty("vadLevelDb"),
migrateThreshold(props.getProperty("vadThresholdDb"), vadThresholdDb));
speechThreshold = parseD(props.getProperty("speechThreshold"), speechThreshold); speechThreshold = parseD(props.getProperty("speechThreshold"), speechThreshold);
vadOverPtt = parseB(props.getProperty("vadOverPtt"), vadOverPtt); vadOverPtt = parseB(props.getProperty("vadOverPtt"), vadOverPtt);
pushToTalkKey = parseI(props.getProperty("pushToTalkKey"), pushToTalkKey);
bitrate = parseI(props.getProperty("bitrate"), bitrate); bitrate = parseI(props.getProperty("bitrate"), bitrate);
complexity = parseI(props.getProperty("complexity"), complexity); complexity = parseI(props.getProperty("complexity"), complexity);
vbr = parseB(props.getProperty("vbr"), vbr); vbr = parseB(props.getProperty("vbr"), vbr);
@@ -161,6 +216,7 @@ public final class Settings {
packetLoss = parseI(props.getProperty("packetLoss"), packetLoss); packetLoss = parseI(props.getProperty("packetLoss"), packetLoss);
music = parseB(props.getProperty("music"), music); music = parseB(props.getProperty("music"), music);
outputVolume = parseD(props.getProperty("outputVolume"), outputVolume); outputVolume = parseD(props.getProperty("outputVolume"), outputVolume);
masterVolume = parseD(props.getProperty("masterVolume"), masterVolume);
inputVolume = parseD(props.getProperty("inputVolume"), inputVolume); inputVolume = parseD(props.getProperty("inputVolume"), inputVolume);
denoise = parseB(props.getProperty("denoise"), denoise); denoise = parseB(props.getProperty("denoise"), denoise);
denoiserLevel = parseD(props.getProperty("denoiserLevel"), denoiserLevel); denoiserLevel = parseD(props.getProperty("denoiserLevel"), denoiserLevel);
@@ -169,6 +225,11 @@ public final class Settings {
soundPack = props.getProperty("soundPack", soundPack); soundPack = props.getProperty("soundPack", soundPack);
soundVolume = parseD(props.getProperty("soundVolume"), soundVolume); soundVolume = parseD(props.getProperty("soundVolume"), soundVolume);
soundPackDir = props.getProperty("soundPackDir", soundPackDir); soundPackDir = props.getProperty("soundPackDir", soundPackDir);
iconPack = props.getProperty("iconPack", iconPack);
iconPackDir = props.getProperty("iconPackDir", iconPackDir);
appearance = parseAppearance(props.getProperty("appearance"), appearance);
showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar);
showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider);
notifications.load(props); notifications.load(props);
} }
@@ -178,14 +239,18 @@ public final class Settings {
props.setProperty("serverPassword", serverPassword); props.setProperty("serverPassword", serverPassword);
props.setProperty("identityFile", identityFile); props.setProperty("identityFile", identityFile);
props.setProperty("defaultIdentityId", defaultIdentityId); props.setProperty("defaultIdentityId", defaultIdentityId);
props.setProperty("customVersion", Boolean.toString(customVersion));
props.setProperty("clientVersion", clientVersion);
props.setProperty("clientPlatform", clientPlatform);
props.setProperty("clientVersionSign", clientVersionSign);
props.setProperty("inputDevice", inputDevice); props.setProperty("inputDevice", inputDevice);
props.setProperty("outputDevice", outputDevice); props.setProperty("outputDevice", outputDevice);
props.setProperty("inputMode", inputMode.name()); props.setProperty("inputMode", inputMode.name());
props.setProperty("vadMode", vadMode.name()); props.setProperty("vadMode", vadMode.name());
props.setProperty("vadThresholdDb", Double.toString(vadThresholdDb)); props.setProperty("vadLevelDb", Double.toString(vadThresholdDb));
props.remove("vadThresholdDb");
props.setProperty("speechThreshold", Double.toString(speechThreshold)); props.setProperty("speechThreshold", Double.toString(speechThreshold));
props.setProperty("vadOverPtt", Boolean.toString(vadOverPtt)); props.setProperty("vadOverPtt", Boolean.toString(vadOverPtt));
props.setProperty("pushToTalkKey", Integer.toString(pushToTalkKey));
props.setProperty("bitrate", Integer.toString(bitrate)); props.setProperty("bitrate", Integer.toString(bitrate));
props.setProperty("complexity", Integer.toString(complexity)); props.setProperty("complexity", Integer.toString(complexity));
props.setProperty("vbr", Boolean.toString(vbr)); props.setProperty("vbr", Boolean.toString(vbr));
@@ -193,6 +258,7 @@ public final class Settings {
props.setProperty("packetLoss", Integer.toString(packetLoss)); props.setProperty("packetLoss", Integer.toString(packetLoss));
props.setProperty("music", Boolean.toString(music)); props.setProperty("music", Boolean.toString(music));
props.setProperty("outputVolume", Double.toString(outputVolume)); props.setProperty("outputVolume", Double.toString(outputVolume));
props.setProperty("masterVolume", Double.toString(masterVolume));
props.setProperty("inputVolume", Double.toString(inputVolume)); props.setProperty("inputVolume", Double.toString(inputVolume));
props.setProperty("denoise", Boolean.toString(denoise)); props.setProperty("denoise", Boolean.toString(denoise));
props.setProperty("denoiserLevel", Double.toString(denoiserLevel)); props.setProperty("denoiserLevel", Double.toString(denoiserLevel));
@@ -201,6 +267,11 @@ public final class Settings {
props.setProperty("soundPack", soundPack); props.setProperty("soundPack", soundPack);
props.setProperty("soundVolume", Double.toString(soundVolume)); props.setProperty("soundVolume", Double.toString(soundVolume));
props.setProperty("soundPackDir", soundPackDir); props.setProperty("soundPackDir", soundPackDir);
props.setProperty("iconPack", iconPack);
props.setProperty("iconPackDir", iconPackDir);
props.setProperty("appearance", appearance.name());
props.setProperty("showStatusBar", Boolean.toString(showStatusBar));
props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider));
notifications.store(props); notifications.store(props);
} }
@@ -213,6 +284,15 @@ public final class Settings {
} }
} }
private static Appearance parseAppearance(String v, Appearance def) {
if (v == null) return def;
try {
return Appearance.valueOf(v);
} catch (IllegalArgumentException e) {
return def;
}
}
private static VadMode parseVadMode(String v, VadMode def) { private static VadMode parseVadMode(String v, VadMode def) {
if (v == null) return def; if (v == null) return def;
try { try {
@@ -222,6 +302,20 @@ public final class Settings {
} }
} }
/**
* Converts a threshold saved under the old {@code vadThresholdDb} key, which was in
* dBFS ({@code 20*log10(rms)}), to {@link com.ts3client.audio.InputLevel}'s scale, so
* upgrading doesn't silently change how sensitive the gate is.
*/
private static double migrateThreshold(String legacy, double def) {
double dbfs = parseD(legacy, Double.NaN);
if (Double.isNaN(dbfs)) {
return def;
}
double converted = com.ts3client.audio.InputLevel.powerToDb(Math.pow(10.0, dbfs / 10.0));
return Math.min(com.ts3client.audio.InputLevel.MAX_DB, converted);
}
private static double parseD(String v, double def) { private static double parseD(String v, double def) {
if (v == null) return def; if (v == null) return def;
try { try {

View File

@@ -0,0 +1,314 @@
package com.ts3client.gfx;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* A TeamSpeak icon pack: a zip (or unpacked directory) of images plus an optional
* {@code settings.ini} naming the file behind every icon of the user interface.
* The format is TeamSpeak's, so the packs shipped with the official client and the
* ones from its add-on site can be used as they are:
*
* <pre>
* [options]
* FALLBACK=true
*
* [gfxfiles]
* CHANNEL_GREEN = channel_green.svg
* CONNECT = connect.svg
* </pre>
*
* <p>Packs without a {@code settings.ini} are the legacy ones (TeamSpeak's own
* {@code default.zip}), whose files are named after the icon key directly, each
* prefixed with the size it was drawn for: {@code 16x16_connect.png}. Those are
* resolved by convention, picking the size closest to the one that is asked for.
*
* <p>Icon data is returned as bytes; rasterising it (the packs from 2014 on are
* vector art) is the frontend's job.
*/
public final class IconPack {
private static final String INI = "settings.ini";
/** Legacy file names: {@code <width>x<height>_<key>.<ext>}. */
private static final Pattern SIZED = Pattern.compile("(\\d+)x(\\d+)_(.+)");
/** One image inside a pack, with the size it was authored at (0 when unknown). */
private record Entry(String path, int size) {
}
private final String id;
private final File source;
private final String name;
private final boolean zipped;
private final boolean fallback;
/** Icon key to the images that can serve it, smallest first. */
private final Map<String, List<Entry>> entries;
private IconPack(String id, File source, String name, boolean zipped, boolean fallback,
Map<String, List<Entry>> entries) {
this.id = id;
this.source = source;
this.name = name;
this.zipped = zipped;
this.fallback = fallback;
this.entries = entries;
}
/** Stable identifier used in the settings: the file (or directory) name. */
public String id() {
return id;
}
public String name() {
return name;
}
public File source() {
return source;
}
/** Whether missing icons should be looked up in the default pack, as the pack asks. */
public boolean fallsBack() {
return fallback;
}
/** All icon keys this pack can draw, sorted. */
public List<String> keys() {
return new ArrayList<>(new TreeSet<>(entries.keySet()));
}
public boolean has(String key) {
return entries.containsKey(normalizeKey(key));
}
/**
* Reads the image for an icon.
*
* @param key a TeamSpeak icon key, e.g. {@code CHANNEL_GREEN}
* @param preferredSize the size it will be drawn at, so packs holding several
* resolutions can pick the closest one
* @return the image bytes (PNG or SVG), or {@code null} if the pack has no such icon
*/
public byte[] icon(String key, int preferredSize) {
Entry entry = select(entries.get(normalizeKey(key)), preferredSize);
if (entry == null) return null;
try {
return zipped ? readFromZip(entry.path()) : Files.readAllBytes(new File(source, entry.path()).toPath());
} catch (IOException e) {
return null;
}
}
/** @return whether the icon's image is vector art the frontend has to rasterise */
public boolean isVector(String key, int preferredSize) {
Entry entry = select(entries.get(normalizeKey(key)), preferredSize);
return entry != null && entry.path().toLowerCase(Locale.ROOT).endsWith(".svg");
}
@Override
public String toString() {
return name;
}
/** The smallest image that still covers the wanted size, else the largest one. */
private static Entry select(List<Entry> candidates, int preferredSize) {
if (candidates == null || candidates.isEmpty()) return null;
for (Entry e : candidates) {
if (e.size() == 0 || e.size() >= preferredSize) return e;
}
return candidates.get(candidates.size() - 1);
}
private byte[] readFromZip(String path) throws IOException {
try (ZipFile zip = new ZipFile(source)) {
ZipEntry entry = zip.getEntry(path);
if (entry == null) return null;
try (InputStream in = zip.getInputStream(entry)) {
return in.readAllBytes();
}
}
}
private static String normalizeKey(String key) {
return key == null ? "" : key.trim().toUpperCase(Locale.ROOT);
}
// ---- loading ----
/**
* Reads a pack from a {@code .zip} file or from a directory holding its contents.
*
* @return the pack, or {@code null} when it holds no icons
*/
public static IconPack load(File source) {
if (source == null) return null;
boolean zipped = source.isFile();
if (!zipped && !source.isDirectory()) return null;
Map<String, Long> files = zipped ? listZip(source) : listDirectory(source);
if (files == null || files.isEmpty()) return null;
String iniPath = files.keySet().stream()
.filter(p -> p.equalsIgnoreCase(INI))
.findFirst().orElse(null);
Map<String, String> mapping = new LinkedHashMap<>();
Map<String, String> info = new LinkedHashMap<>();
boolean fallback = true;
if (iniPath != null) {
byte[] ini = read(source, zipped, iniPath);
if (ini != null) fallback = parseIni(ini, mapping, info);
}
Map<String, List<Entry>> entries = iniPath != null
? fromMapping(mapping, files)
: byConvention(files);
if (entries.isEmpty()) return null;
String id = source.getName();
String name = info.getOrDefault("name", displayName(id));
return new IconPack(id, source, name, zipped, fallback, entries);
}
/** Turns {@code default_colored_2014.zip} into {@code Default Colored 2014}. */
private static String displayName(String fileName) {
String base = fileName.toLowerCase(Locale.ROOT).endsWith(".zip")
? fileName.substring(0, fileName.length() - 4) : fileName;
StringBuilder out = new StringBuilder();
for (String word : base.split("[_\\-\\s]+")) {
if (word.isEmpty()) continue;
if (out.length() > 0) out.append(' ');
out.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return out.length() == 0 ? fileName : out.toString();
}
/** @return the pack's FALLBACK option */
private static boolean parseIni(byte[] data, Map<String, String> mapping, Map<String, String> info) {
boolean fallback = true;
try (BufferedReader in = new BufferedReader(
new InputStreamReader(new java.io.ByteArrayInputStream(data), StandardCharsets.UTF_8))) {
String section = "";
String line;
while ((line = in.readLine()) != null) {
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
section = trimmed.substring(1, trimmed.length() - 1).trim().toLowerCase(Locale.ROOT);
continue;
}
int eq = trimmed.indexOf('=');
if (eq < 0) continue;
String key = trimmed.substring(0, eq).trim();
String value = trimmed.substring(eq + 1).trim();
switch (section) {
case "gfxfiles" -> {
if (!value.isEmpty()) mapping.put(normalizeKey(key), value);
}
case "info" -> info.put(key.toLowerCase(Locale.ROOT), value);
case "options" -> {
if (key.equalsIgnoreCase("FALLBACK")) fallback = !value.equalsIgnoreCase("false");
}
default -> {
}
}
}
} catch (IOException ignored) {
}
return fallback;
}
private static Map<String, List<Entry>> fromMapping(Map<String, String> mapping, Map<String, Long> files) {
Map<String, List<Entry>> entries = new LinkedHashMap<>();
for (Map.Entry<String, String> e : mapping.entrySet()) {
String path = resolve(e.getValue(), files);
if (path != null) entries.put(e.getKey(), List.of(new Entry(path, 0)));
}
return entries;
}
/**
* Legacy packs (and the extra files of newer ones) are keyed by their name:
* {@code 16x16_connect.png} serves {@code CONNECT}, as does {@code 32x32_connect.png}
* when no smaller one exists.
*/
private static Map<String, List<Entry>> byConvention(Map<String, Long> files) {
Map<String, List<Entry>> entries = new LinkedHashMap<>();
for (String path : files.keySet()) {
int dot = path.lastIndexOf('.');
if (dot < 0 || path.indexOf('/') >= 0) continue;
String ext = path.substring(dot + 1).toLowerCase(Locale.ROOT);
if (!ext.equals("png") && !ext.equals("svg")) continue;
String base = path.substring(0, dot);
int size = 0;
Matcher m = SIZED.matcher(base);
if (m.matches()) {
size = Integer.parseInt(m.group(1));
base = m.group(3);
}
entries.computeIfAbsent(normalizeKey(base), k -> new ArrayList<>()).add(new Entry(path, size));
}
for (List<Entry> candidates : entries.values()) {
candidates.sort((a, b) -> Integer.compare(a.size(), b.size()));
}
return entries;
}
/** Pack authors write file names as they see them, so a mismatched case is retried. */
private static String resolve(String fileName, Map<String, Long> files) {
if (files.containsKey(fileName)) return fileName;
for (String path : files.keySet()) {
if (path.equalsIgnoreCase(fileName)) return path;
}
return null;
}
private static byte[] read(File source, boolean zipped, String path) {
try {
if (!zipped) return Files.readAllBytes(new File(source, path).toPath());
try (ZipFile zip = new ZipFile(source)) {
ZipEntry entry = zip.getEntry(path);
if (entry == null) return null;
try (InputStream in = zip.getInputStream(entry)) {
return in.readAllBytes();
}
}
} catch (IOException e) {
return null;
}
}
private static Map<String, Long> listZip(File file) {
if (!file.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) return null;
Map<String, Long> files = new LinkedHashMap<>();
try (ZipFile zip = new ZipFile(file)) {
zip.stream().filter(e -> !e.isDirectory()).forEach(e -> files.put(e.getName(), e.getSize()));
} catch (IOException e) {
return null;
}
return files;
}
private static Map<String, Long> listDirectory(File dir) {
Map<String, Long> files = new LinkedHashMap<>();
File[] children = dir.listFiles(File::isFile);
if (children == null) return files;
for (File f : children) files.put(f.getName(), f.length());
return files;
}
}

View File

@@ -0,0 +1,64 @@
package com.ts3client.gfx;
import com.ts3client.config.InstallDirs;
import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Finds the icon packs installed on this machine: the client's own {@code gfx/}
* folder, an extra folder the user configured, and the {@code gfx/} folder of an
* installed TeamSpeak 3 client (whose packs are usable as they are, see
* {@link IconPack}).
*/
public final class IconPacks {
/** The pack every other one falls back to, as TeamSpeak does. */
public static final String DEFAULT_ID = "default.zip";
private IconPacks() {
}
/**
* Loads every pack found under {@code extraDirectory} and the well-known
* locations, the first search root winning on a name clash.
*
* @param userDirectory the client's own pack folder (may not exist yet)
* @param extraDirectory an additional folder chosen by the user, or {@code null}
* @return the packs, ordered by display name
*/
public static List<IconPack> findAll(File userDirectory, String extraDirectory) {
Map<String, IconPack> byId = new LinkedHashMap<>();
for (File root : InstallDirs.searchRoots("gfx", userDirectory, extraDirectory)) {
File[] children = root.listFiles();
if (children == null) continue;
for (File child : children) {
if (child.isFile() && !child.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) continue;
if (byId.containsKey(child.getName())) continue;
IconPack pack = IconPack.load(child);
if (pack != null) byId.put(pack.id(), pack);
}
}
List<IconPack> packs = new ArrayList<>(byId.values());
packs.sort(Comparator.comparing(p -> p.name().toLowerCase(Locale.ROOT)));
return packs;
}
/** @return the pack with this id, or {@code null} when it is not installed */
public static IconPack select(List<IconPack> packs, String id) {
for (IconPack p : packs) {
if (p.id().equals(id)) return p;
}
return null;
}
/** @return the pack unresolved icons are taken from, or {@code null} */
public static IconPack fallback(List<IconPack> packs) {
return select(packs, DEFAULT_ID);
}
}

View File

@@ -0,0 +1,38 @@
package com.ts3client.hotkey;
/**
* A system-wide source of key and mouse-button events: it reports every press and
* release on the machine, whether or not the client has focus, and without swallowing
* the event on its way to the focused application.
*
* <p>Implementations are platform code and live outside the core; the core only ever
* sees this interface.
*/
public interface GlobalInputHook extends AutoCloseable {
interface Listener {
/**
* @param key the key or button
* @param pressed {@code true} for a press, {@code false} for a release
*/
void onInput(HotkeyKey key, boolean pressed);
}
/** Starts delivering events on a background thread. */
void start(Listener listener);
/** Whether events are actually being delivered; false when the platform refused. */
boolean isRunning();
/** Why the hook is not running, for the options dialog to show; empty when it is. */
String unavailableReason();
/**
* The name this system's layout gives a key, or {@code null} to let the caller fall
* back to {@link HotkeyKey#fallbackName()}.
*/
String keyName(HotkeyKey key);
@Override
void close();
}

View File

@@ -0,0 +1,74 @@
package com.ts3client.hotkey;
/**
* A single binding: which combination triggers which action, on which edge of the
* keypress, and whether it reaches only the active server or every connected one.
*/
public final class Hotkey {
/** Which edge of the key press runs the action, as TS3's hotkey mode combo box offers. */
public enum Trigger {
/** Action triggers when the key is pressed down. */
KEY_DOWN("On key down"),
/** Action triggers when the key is released. */
KEY_UP("On key up");
private final String label;
Trigger(String label) {
this.label = label;
}
public String label() {
return label;
}
}
public HotkeyAction action;
public HotkeyCombo combo;
public Trigger trigger = Trigger.KEY_DOWN;
/** Apply to the active server only; when false the action reaches every connection. */
public boolean activeServerOnly = true;
/** Meaning depends on {@link HotkeyAction#argument()}; empty when the action takes none. */
public String argument = "";
public boolean enabled = true;
public Hotkey() {
}
public Hotkey(HotkeyAction action, HotkeyCombo combo) {
this.action = action;
this.combo = combo;
}
/**
* The binding's place in the action tree, as TS3 spells it out:
* {@code Sounds / Activate Soundpack / Default Sound Pack (Male)}.
*/
public String path() {
if (action == null) return "";
StringBuilder sb = new StringBuilder();
if (!action.group().isEmpty()) sb.append(action.group()).append(" / ");
sb.append(action.label());
if (argument != null && !argument.isBlank()) sb.append(" / ").append(argument.trim());
return sb.toString();
}
public boolean isValid() {
return action != null && combo != null && !combo.isEmpty();
}
/** Whether the action is a per-server one, for which {@link #activeServerOnly} matters. */
public boolean isServerScoped() {
return action != null && action.category() != HotkeyAction.Category.MISC;
}
public Hotkey copy() {
Hotkey h = new Hotkey(action, combo);
h.trigger = trigger;
h.activeServerOnly = activeServerOnly;
h.argument = argument;
h.enabled = enabled;
return h;
}
}

View File

@@ -0,0 +1,366 @@
package com.ts3client.hotkey;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* The catalogue of hotkey actions, reverse-engineered from the official TeamSpeak 3
* client (its hotkey dialog builds exactly this list, in this order).
*
* <p>Every entry keeps the identifiers TS3 itself uses — a numeric action type and a
* parameter keyword — so bindings stay recognisable and could be imported from, or
* exported to, the original client. {@link #supported()} tells whether this client
* can actually carry the action out; the rest are listed but greyed out.
*
* <p>{@link #advanced()} mirrors the dialog's "Show Advanced Actions" checkbox: TS3
* hides all but a handful of everyday actions until it is ticked.
*/
public enum HotkeyAction {
// ----- Server -----
CONNECT_CURRENT_TAB(Category.SERVER, 0x0010, "Server (current tab)",
"Connect to Server on Current Tab", false, Argument.BOOKMARK, true),
CONNECT_NEW_TAB(Category.SERVER, 0x0010, "Server (new tab)",
"Connect to Server in a New Tab", false, Argument.BOOKMARK, true),
DISCONNECT_CURRENT(Category.SERVER, 0x0020, "Current Server",
"Disconnect from Current Server", false, Argument.NONE, true),
DISCONNECT_ALL(Category.SERVER, 0x0020, "All Servers",
"Disconnect from All Servers", false, Argument.NONE, true),
MIC_ACTIVATE(Category.SERVER, 0x0030, "Activate",
"Activate Microphone (current tab)", true, Argument.NONE, true),
MIC_MUTE(Category.SERVER, 0x0030, "Mute",
"Mute Microphone", true, Argument.NONE, true),
MIC_UNMUTE(Category.SERVER, 0x0030, "Unmute",
"Unmute Microphone", true, Argument.NONE, true),
MIC_TOGGLE(Category.SERVER, 0x0030, "Toggle",
"Toggle Microphone Mute", false, Argument.NONE, true),
/**
* TS3's "local" mute silences capture without publishing a mute status, or playing
* a mute/unmute sound — unlike {@link #MIC_MUTE}.
*/
MIC_LOCAL_UNMUTE(Category.SERVER, 0x0030, "ActivateLocalMute",
"Disable Local Mic Mute", true, Argument.NONE, true),
MIC_LOCAL_MUTE(Category.SERVER, 0x0030, "DeactivateLocalMute",
"Enable Local Mic Mute", true, Argument.NONE, true),
MIC_LOCAL_TOGGLE(Category.SERVER, 0x0030, "ToggleLocalMute",
"Toggle Local Mic Mute", true, Argument.NONE, true),
SPEAKER_MUTE(Category.SERVER, 0x0040, "Mute",
"Mute Speaker", true, Argument.NONE, true),
SPEAKER_UNMUTE(Category.SERVER, 0x0040, "Unmute",
"Unmute Speaker", true, Argument.NONE, true),
SPEAKER_TOGGLE(Category.SERVER, 0x0040, "Toggle",
"Toggle Speaker Mute", false, Argument.NONE, true),
AWAY_SET(Category.SERVER, 0x0050, "SetAway",
"Set Away", true, Argument.NONE, true),
AWAY_ONLINE(Category.SERVER, 0x0050, "SetOnline",
"Set Online", true, Argument.NONE, true),
AWAY_TOGGLE(Category.SERVER, 0x0050, "Toggle",
"Toggle Away Status", false, Argument.NONE, true),
AWAY_TOGGLE_WITH_MESSAGE(Category.SERVER, 0x0050, "Toggle With Message",
"Toggle Away Status with Message", true, Argument.TEXT, true),
COMMANDER_ACTIVATE(Category.SERVER, 0x0100, "Activate",
"Activate Channel Commander", true, Argument.NONE, true),
COMMANDER_DEACTIVATE(Category.SERVER, 0x0100, "Deactivate",
"Deactivate Channel Commander", true, Argument.NONE, true),
COMMANDER_TOGGLE(Category.SERVER, 0x0100, "Toggle",
"Toggle Channel Commander", true, Argument.NONE, true),
// ----- Self -----
CAPTURE_PROFILE_ACTIVATE(Category.SELF, 0x0110, "Activate",
"Activate Capture Profile", true, Argument.PROFILE, false),
CAPTURE_PROFILE_DEACTIVATE(Category.SELF, 0x0110, "Deactivate",
"Deactivate Capture Profile", true, Argument.PROFILE, false),
CAPTURE_PROFILE_TOGGLE(Category.SELF, 0x0110, "Toggle",
"Toggle Capture Profile", true, Argument.PROFILE, false),
PLAYBACK_PROFILE_ACTIVATE(Category.SELF, 0x0120, "Activate",
"Activate Playback Profile", true, Argument.PROFILE, false),
PLAYBACK_PROFILE_DEACTIVATE(Category.SELF, 0x0120, "Deactivate",
"Deactivate Playback Profile", true, Argument.PROFILE, false),
PLAYBACK_PROFILE_TOGGLE(Category.SELF, 0x0120, "Toggle",
"Toggle Playback Profile", true, Argument.PROFILE, false),
HOTKEY_PROFILE_ACTIVATE(Category.SELF, 0x0130, "Activate",
"Activate Hotkey Profile", true, Argument.PROFILE, false),
HOTKEY_PROFILE_DEACTIVATE(Category.SELF, 0x0130, "Deactivate",
"Deactivate Hotkey Profile", true, Argument.PROFILE, false),
PTT_ACTIVATE(Category.SELF, 0x0140, "Activate",
"Activate Push-to-Talk", false, Argument.NONE, true, true),
PTT_DEACTIVATE(Category.SELF, 0x0140, "Deactivate",
"Deactivate Push-to-Talk", true, Argument.NONE, true),
PTT_TOGGLE(Category.SELF, 0x0140, "Toggle",
"Toggle Push-to-Talk", true, Argument.NONE, true),
CHANNEL_SWITCH(Category.SELF, 0x0150, "Strict Channel",
"Switch to Channel", true, Argument.CHANNEL, true),
CHANNEL_NEXT(Category.SELF, 0x0150, "Next Channel",
"Switch to Next Channel (Global)", true, Argument.NONE, false),
CHANNEL_PREVIOUS(Category.SELF, 0x0150, "Previous Channel",
"Switch to Previous Channel (Global)", true, Argument.NONE, false),
CHANNEL_LAST_VISITED(Category.SELF, 0x0150, "Last Visited Channel",
"Switch to Last Visited Channel", true, Argument.NONE, false),
CHANNEL_NEXT_FAMILY(Category.SELF, 0x0150, "Next Channel Family",
"Switch to Next Channel (Channel Family)", true, Argument.NONE, false),
CHANNEL_PREVIOUS_FAMILY(Category.SELF, 0x0150, "Previous Channel Family",
"Switch to Previous Channel (Channel Family)", true, Argument.NONE, false),
CHANNEL_NEXT_LEVEL(Category.SELF, 0x0150, "Next Channel Level",
"Switch to Next Channel (Same Level)", true, Argument.NONE, false),
CHANNEL_PREVIOUS_LEVEL(Category.SELF, 0x0150, "Previous Channel Level",
"Switch to Previous Channel (Same Level)", true, Argument.NONE, false),
SERVER_TAB_SELECT(Category.SELF, 0x0160, "Strict Server",
"Select Server Tab", true, Argument.TEXT, true),
SERVER_TAB_NEXT(Category.SELF, 0x0160, "Next Server",
"Select Next Server Tab", true, Argument.NONE, true),
SERVER_TAB_PREVIOUS(Category.SELF, 0x0160, "Previous Server",
"Select Previous Server Tab", true, Argument.NONE, true),
SOUNDPACK_ACTIVATE(Category.SELF, 0x0170, "Activate",
"Activate Soundpack", true, Argument.PROFILE, false),
SOUND_MUTE(Category.SELF, 0x0170, "Mute",
"Mute Sounds", true, Argument.NONE, true),
SOUND_UNMUTE(Category.SELF, 0x0170, "Unmute",
"Unmute Sounds", true, Argument.NONE, true),
SOUND_TOGGLE(Category.SELF, 0x0170, "Toggle",
"Toggle Sound Mute", true, Argument.NONE, true),
WHISPER_PUSH_ACTIVATE(Category.SELF, 0x0180, "ActivatePushToWhisper",
"Activate Push-To-Whisper", true, Argument.TEXT, false, true),
WHISPER_PUSH_DEACTIVATE(Category.SELF, 0x0180, "DeactivatePushToWhisper",
"Deactivate Push-To-Whisper", true, Argument.TEXT, false),
WHISPER_REPLY_PUSH_ACTIVATE(Category.SELF, 0x0180, "ActivateReplyPushToWhisper",
"Activate Push-To-Reply to a Whisper", true, Argument.NONE, false, true),
WHISPER_REPLY_PUSH_DEACTIVATE(Category.SELF, 0x0180, "DeactivateReplyPushToWhisper",
"Deactivate Push-To-Reply to a Whisper", true, Argument.NONE, false),
WHISPERLIST_ACTIVATE(Category.SELF, 0x0180, "Activate",
"Activate Whisperlist", true, Argument.TEXT, false),
WHISPERLIST_DEACTIVATE(Category.SELF, 0x0180, "Deactivate",
"Deactivate Whisperlist", true, Argument.TEXT, false),
WHISPER_REPLY_ACTIVATE(Category.SELF, 0x0180, "ActivateReply",
"Activate Reply to a Whisper", true, Argument.NONE, false),
WHISPER_REPLY_DEACTIVATE(Category.SELF, 0x0180, "DeactivateReply",
"Deactivate Reply to a Whisper", true, Argument.NONE, false),
WHISPER_BLOCK_ACTIVATE(Category.SELF, 0x0180, "ActivateBlock",
"Activate Block Incoming Whispers", true, Argument.NONE, false),
WHISPER_BLOCK_DEACTIVATE(Category.SELF, 0x0180, "DeactivateBlock",
"Deactivate Block Incoming Whispers", true, Argument.NONE, false),
WHISPER_BLOCK_TOGGLE(Category.SELF, 0x0180, "ToggleBlock",
"Toggle Block Incoming Whispers", true, Argument.NONE, false),
RECORDING_START(Category.SELF, 0x01c0, "Activate",
"Start Recording", true, Argument.NONE, false),
RECORDING_START_MULTITRACK(Category.SELF, 0x01c0, "ActivateMT",
"Start Multitrack Recording", true, Argument.NONE, false),
RECORDING_STOP(Category.SELF, 0x01c0, "Deactivate",
"Stop Recording", true, Argument.NONE, false),
VOLUME_INCREASE(Category.SELF, 0x01e0, "Increase",
"Increase Master Volume", true, Argument.NONE, true),
VOLUME_DECREASE(Category.SELF, 0x01e0, "Decrease",
"Decrease Master Volume", true, Argument.NONE, true),
PLUGIN_ACTIVATE(Category.SELF, 0x01f0, "Activate",
"Activate Plugin", true, Argument.TEXT, false),
PLUGIN_DEACTIVATE(Category.SELF, 0x01f0, "Deactivate",
"Deactivate Plugin", true, Argument.TEXT, false),
PLUGIN_TOGGLE(Category.SELF, 0x01f0, "Toggle",
"Toggle Plugin", true, Argument.TEXT, false),
PLUGIN_COMMAND(Category.SELF, 0x01f0, "Run",
"Run Plugin Command", true, Argument.TEXT, false),
PLUGIN_HOTKEY(Category.SELF, 0x01f0, "Hotkey",
"Plugin Hotkey", true, Argument.TEXT, false),
SERVER_GROUP_ASSIGN(Category.SELF, 0x0270, "AssignSG",
"Assign Server Group", true, Argument.TEXT, false),
SERVER_GROUP_REVOKE(Category.SELF, 0x0270, "RevokeSG",
"Revoke Server Group", true, Argument.TEXT, false),
SERVER_GROUP_TOGGLE(Category.SELF, 0x0270, "ToggleSG",
"Toggle Server Group", true, Argument.TEXT, false),
STYLESHEET_HELPER_ON(Category.SELF, 0x0240, "Activate",
"Stylesheet Helper (on)", true, Argument.NONE, false),
STYLESHEET_HELPER_OFF(Category.SELF, 0x0240, "Deactivate",
"Stylesheet Helper (off)", true, Argument.NONE, false),
SOUND_3D_ACTIVATE(Category.SELF, 0x0250, "Activate",
"Activate 3D Sound", true, Argument.NONE, false),
SOUND_3D_DEACTIVATE(Category.SELF, 0x0250, "Deactivate",
"Deactivate 3D Sound", true, Argument.NONE, false),
SOUND_3D_TOGGLE(Category.SELF, 0x0250, "Toggle",
"Toggle 3D Sound", true, Argument.NONE, false),
NICKNAME_CHANGE(Category.SELF, 0x01a0, "Rename",
"Change Nickname", true, Argument.TEXT, true),
// ----- Misc -----
TALK_POWER_GRANT_NEXT(Category.MISC, 0x01b0, "GrantNextTalkPower",
"Grant Next User Talk Power", true, Argument.NONE, false),
TALK_POWER_REQUEST(Category.MISC, 0x01b0, "RequestTalkPower",
"Request Talk Power", true, Argument.NONE, false),
TALK_POWER_REVOKE_ALL_GRANT_NEXT(Category.MISC, 0x01b0, "RevokeAllAndGrantNext",
"Revoke All And Grant Next User Talk Power", true, Argument.NONE, false),
FILEBROWSER(Category.MISC, 0x01d0, "Filebrowser",
"Open Filebrowser on Channel", true, Argument.NONE, true),
AUTOCONNECT_DISABLE(Category.MISC, 0x0200, "Autoconnect",
"Disable Autoconnect", true, Argument.NONE, false),
SKIN_RELOAD(Category.MISC, 0x0210, "SkinReload",
"Reload Skin", true, Argument.NONE, true),
BRING_TO_FRONT(Category.MISC, 0x0220, "Bring2Front",
"Bring Client to Front", true, Argument.NONE, true),
SEND_TO_BACK(Category.MISC, 0x0230, "Send2Back",
"Send Client to Background", true, Argument.NONE, true);
/** The three groups the TS3 hotkey dialog separates its list into. */
public enum Category {
SERVER("Server"),
SELF("Self"),
MISC("Misc");
private final String label;
Category(String label) {
this.label = label;
}
public String label() {
return label;
}
}
/**
* The group an action's type belongs to in TeamSpeak's hotkey tree, taken from the
* table its dialog builds. Types absent here are ungrouped: TS3 shows them as a
* single item directly under their category.
*/
private static final Map<Integer, String> GROUPS = Map.ofEntries(
Map.entry(0x0010, "Connect to Server"),
Map.entry(0x0020, "Disconnect from Server"),
Map.entry(0x0030, "Microphone"),
Map.entry(0x0040, "Speaker"),
Map.entry(0x0050, "Away Status"),
Map.entry(0x0100, "Channel Commander"),
Map.entry(0x0110, "Capture Profile"),
Map.entry(0x0120, "Playback Profile"),
Map.entry(0x0130, "Hotkey Profile"),
Map.entry(0x0140, "Push-to-Talk"),
Map.entry(0x0150, "Switch to Channel"),
Map.entry(0x0160, "Select Server Tab"),
Map.entry(0x0170, "Sounds"),
Map.entry(0x0180, "Whisper"),
Map.entry(0x01b0, "Talk Power"),
Map.entry(0x01c0, "Recording"),
Map.entry(0x01e0, "Master Volume"),
Map.entry(0x01f0, "Plugins"),
Map.entry(0x0250, "3D Sound"),
Map.entry(0x0270, "Permissions"));
/** What the action's free-text parameter means, when it takes one. */
public enum Argument {
NONE,
/** Label of a bookmark to connect to. */
BOOKMARK,
/** Channel path, "/"-separated. */
CHANNEL,
/** Name of a capture/playback/hotkey profile or sound pack. */
PROFILE,
/** Anything else: nickname, away message, tab number, plugin command. */
TEXT
}
private final Category category;
private final int type;
private final String parameter;
private final String label;
private final boolean advanced;
private final Argument argument;
private final boolean supported;
private final boolean momentary;
HotkeyAction(Category category, int type, String parameter, String label,
boolean advanced, Argument argument, boolean supported) {
this(category, type, parameter, label, advanced, argument, supported, false);
}
HotkeyAction(Category category, int type, String parameter, String label,
boolean advanced, Argument argument, boolean supported, boolean momentary) {
this.category = category;
this.type = type;
this.parameter = parameter;
this.label = label;
this.advanced = advanced;
this.argument = argument;
this.supported = supported;
this.momentary = momentary;
}
public Category category() {
return category;
}
/** TS3's numeric action type; actions sharing one differ only by {@link #parameter()}. */
public int type() {
return type;
}
/**
* The node this action hangs under in the hotkey tree ("Sounds", "Microphone", …),
* or an empty string for the actions TS3 lists on their own.
*/
public String group() {
return GROUPS.getOrDefault(type, "");
}
/** TS3's parameter keyword, e.g. {@code Toggle} or {@code ActivateReply}. */
public String parameter() {
return parameter;
}
/** The name shown in the hotkey list. */
public String label() {
return label;
}
/** Hidden until "Show advanced actions" is ticked, as in TS3. */
public boolean advanced() {
return advanced;
}
public Argument argument() {
return argument;
}
/** Whether this client implements the action; unsupported ones cannot be bound. */
public boolean supported() {
return supported;
}
/**
* Whether the action lasts only while the hotkey is held (push-to-talk and the
* push-to-whisper variants), in which case the trigger setting does not apply and
* the release is reported as well.
*/
public boolean momentary() {
return momentary;
}
public static List<HotkeyAction> of(Category category, boolean includeAdvanced) {
List<HotkeyAction> out = new ArrayList<>();
for (HotkeyAction a : values()) {
if (a.category == category && (includeAdvanced || !a.advanced)) out.add(a);
}
return out;
}
/** Looks an action up by its enum name, falling back to {@code null}. */
public static HotkeyAction byName(String name) {
for (HotkeyAction a : values()) {
if (a.name().equals(name)) return a;
}
return null;
}
}

View File

@@ -0,0 +1,94 @@
package com.ts3client.hotkey;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
/**
* A key combination: one key or button, or several that have to be held together.
* There is no distinguished modifier — as in TS3, any key can take that role, and the
* combination fires once every one of its keys is down.
*/
public final class HotkeyCombo {
/** Recording order, which is also the order the combination is displayed in. */
private final List<HotkeyKey> keys;
public HotkeyCombo(Collection<HotkeyKey> keys) {
List<HotkeyKey> copy = new ArrayList<>();
for (HotkeyKey k : keys) {
if (k != null && !copy.contains(k)) copy.add(k);
}
this.keys = Collections.unmodifiableList(copy);
}
public List<HotkeyKey> keys() {
return keys;
}
public boolean isEmpty() {
return keys.isEmpty();
}
public boolean contains(HotkeyKey key) {
return keys.contains(key);
}
/** Whether every key of the combination is currently held down. */
public boolean isSatisfiedBy(Set<HotkeyKey> pressed) {
return !keys.isEmpty() && pressed.containsAll(keys);
}
/** Serialised form, e.g. {@code Keyboard:37+Keyboard:45}. */
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (HotkeyKey k : keys) {
if (sb.length() > 0) sb.append('+');
sb.append(k);
}
return sb.toString();
}
public static HotkeyCombo parse(String text) {
List<HotkeyKey> keys = new ArrayList<>();
if (text != null && !text.isBlank()) {
for (String part : text.split("\\+")) {
HotkeyKey k = HotkeyKey.parse(part);
if (k != null) keys.add(k);
}
}
return new HotkeyCombo(keys);
}
/**
* Renders the combination for the user, e.g. {@code Ctrl+Mouse 4}.
*
* @param namer resolves a key to its name on this system, typically from the
* keyboard layout; may return {@code null} to fall back
*/
public String display(Function<HotkeyKey, String> namer) {
if (keys.isEmpty()) return "No hotkey assigned";
StringBuilder sb = new StringBuilder();
for (HotkeyKey k : keys) {
String name = namer == null ? null : namer.apply(k);
if (name == null || name.isBlank()) name = k.fallbackName();
if (sb.length() > 0) sb.append(" + ");
sb.append(name);
}
return sb.toString();
}
@Override
public boolean equals(Object o) {
return o instanceof HotkeyCombo other && keys.equals(other.keys);
}
@Override
public int hashCode() {
return keys.hashCode();
}
}

View File

@@ -0,0 +1,154 @@
package com.ts3client.hotkey;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Turns the raw stream of global key events into hotkey activations: it tracks which
* keys are down, decides when a binding's combination becomes (or stops being)
* satisfied, and reports that on the configured edge.
*
* <p>While a {@link Recorder} is installed the engine records combinations instead of
* firing them, which is how the hotkey dialog captures a new binding without the old
* ones going off.
*/
public final class HotkeyEngine implements GlobalInputHook.Listener {
/** Receives activations; called on the input hook's thread. */
public interface Handler {
/**
* @param hotkey the binding that fired
* @param active {@code true} on activation; a {@code false} call follows on release
* for {@link HotkeyAction#momentary()} actions only
*/
void onHotkey(Hotkey hotkey, boolean active);
}
/** Collects a combination as the user presses it, for the "set hotkey" button. */
public interface Recorder {
/** The keys held so far, in press order; called on every change. */
void onRecording(HotkeyCombo combo);
/** The user let go of everything: this is the final combination. */
void onRecorded(HotkeyCombo combo);
}
private final Hotkeys hotkeys;
private final Handler handler;
private final Set<HotkeyKey> pressed = new LinkedHashSet<>();
/** Bindings whose combination is currently held, so a release can end them. */
private final Set<Hotkey> active = new HashSet<>();
private volatile Recorder recorder;
private final Set<HotkeyKey> recording = new LinkedHashSet<>();
public HotkeyEngine(Hotkeys hotkeys, Handler handler) {
this.hotkeys = hotkeys;
this.handler = handler;
}
/**
* Diverts every event into {@code recorder} until {@link #stopRecording()}; the
* bindings themselves stay silent meanwhile.
*/
public void record(Recorder recorder) {
synchronized (this) {
recording.clear();
releaseActive();
pressed.clear();
}
this.recorder = recorder;
}
public void stopRecording() {
recorder = null;
synchronized (this) {
recording.clear();
pressed.clear();
}
}
@Override
public void onInput(HotkeyKey key, boolean down) {
Recorder rec = recorder;
if (rec != null) {
recordKey(rec, key, down);
return;
}
List<Runnable> fired = new ArrayList<>();
synchronized (this) {
if (down) {
if (!pressed.add(key)) return;
} else if (!pressed.remove(key)) {
return;
}
synchronized (hotkeys.all()) {
for (Hotkey h : hotkeys.all()) {
if (!h.enabled || !h.isValid() || !h.action.supported()) continue;
if (!h.combo.contains(key)) continue;
boolean satisfied = h.combo.isSatisfiedBy(pressed);
if (satisfied && active.add(h)) {
if (h.action.momentary() || h.trigger == Hotkey.Trigger.KEY_DOWN) {
fired.add(() -> handler.onHotkey(h, true));
}
} else if (!satisfied && active.remove(h)) {
if (h.action.momentary()) {
fired.add(() -> handler.onHotkey(h, false));
} else if (h.trigger == Hotkey.Trigger.KEY_UP) {
fired.add(() -> handler.onHotkey(h, true));
}
}
}
}
}
for (Runnable r : fired) r.run();
}
private void recordKey(Recorder rec, HotkeyKey key, boolean down) {
HotkeyCombo combo;
boolean finished;
synchronized (this) {
if (down) {
recording.add(key);
pressed.add(key);
} else {
pressed.remove(key);
}
combo = new HotkeyCombo(recording);
finished = !down && pressed.isEmpty() && !recording.isEmpty();
if (finished) recording.clear();
}
if (finished) {
rec.onRecorded(combo);
} else {
rec.onRecording(combo);
}
}
/** Ends every held binding, so nothing stays stuck when the hook stops. */
public void releaseAll() {
List<Runnable> fired = new ArrayList<>();
synchronized (this) {
for (Hotkey h : active) {
if (h.action.momentary()) fired.add(() -> handler.onHotkey(h, false));
}
active.clear();
pressed.clear();
}
for (Runnable r : fired) r.run();
}
private void releaseActive() {
for (Hotkey h : new ArrayList<>(active)) {
if (h.action.momentary()) handler.onHotkey(h, false);
}
active.clear();
}
}

View File

@@ -0,0 +1,88 @@
package com.ts3client.hotkey;
/**
* One physical key or mouse button. Keys are identified by their position rather than by
* the character they produce, so a binding is independent of the keyboard layout in force
* when it was recorded.
*
* <p>The keyboard numbering is whatever the platform's own is — X keycodes (Linux evdev
* code + 8) on X11, set-1 scan codes with the {@code E0}/{@code E1} escape in the high
* byte on Windows — because that is what its input API reports and its key-naming call
* expects. Bindings are therefore per-machine, which is also how TeamSpeak stores them.
* Mouse buttons are unified on the X numbering everywhere, side buttons included.
*
* @param device which input device the code belongs to
* @param code the platform's key code for {@link Device#KEYBOARD}, an X-style button
* number for {@link Device#MOUSE}
*/
public record HotkeyKey(HotkeyKey.Device device, int code) {
public enum Device {
KEYBOARD("Keyboard"),
MOUSE("Mouse");
private final String prefix;
Device(String prefix) {
this.prefix = prefix;
}
String prefix() {
return prefix;
}
}
public static HotkeyKey keyboard(int keycode) {
return new HotkeyKey(Device.KEYBOARD, keycode);
}
public static HotkeyKey mouse(int button) {
return new HotkeyKey(Device.MOUSE, button);
}
/** Serialised form, matching TS3's {@code Keyboard:9} / {@code Mouse:9} notation. */
@Override
public String toString() {
return device.prefix() + ":" + code;
}
public static HotkeyKey parse(String text) {
if (text == null) return null;
int colon = text.indexOf(':');
if (colon <= 0) return null;
String prefix = text.substring(0, colon).trim();
int code;
try {
code = Integer.parseInt(text.substring(colon + 1).trim());
} catch (NumberFormatException e) {
return null;
}
for (Device d : Device.values()) {
if (d.prefix().equalsIgnoreCase(prefix)) return new HotkeyKey(d, code);
}
return null;
}
/**
* A readable name that needs no help from the platform: mouse buttons carry the
* numbering users know them by, keys fall back to their raw code.
*/
public String fallbackName() {
if (device == Device.MOUSE) {
return switch (code) {
case 1 -> "Mouse Left";
case 2 -> "Mouse Middle";
case 3 -> "Mouse Right";
case 4 -> "Wheel Up";
case 5 -> "Wheel Down";
case 6 -> "Wheel Left";
case 7 -> "Wheel Right";
// X hands the side buttons out as 8 and 9; everyone else calls them 4 and 5.
case 8 -> "Mouse 4";
case 9 -> "Mouse 5";
default -> "Mouse " + code;
};
}
return "Key " + code;
}
}

View File

@@ -0,0 +1,109 @@
package com.ts3client.hotkey;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
/**
* The user's hotkey bindings, stored alongside the settings file.
* Frontend-agnostic: no UI dependencies.
*/
public final class Hotkeys {
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
private static final File FILE = new File(DIR, "hotkeys.properties");
private final List<Hotkey> entries = Collections.synchronizedList(new ArrayList<>());
/** Live view; iterate under {@code synchronized (all())} when the engine may be running. */
public List<Hotkey> all() {
return entries;
}
public void add(Hotkey hotkey) {
entries.add(hotkey);
}
public void set(int index, Hotkey hotkey) {
if (index >= 0 && index < entries.size()) entries.set(index, hotkey);
}
public void remove(int index) {
if (index >= 0 && index < entries.size()) entries.remove(index);
}
/** Replaces every binding at once, e.g. when the options dialog is applied. */
public void replaceAll(List<Hotkey> hotkeys) {
synchronized (entries) {
entries.clear();
for (Hotkey h : hotkeys) {
if (h != null && h.isValid()) entries.add(h);
}
}
}
public static Hotkeys load() {
Hotkeys h = new Hotkeys();
if (!FILE.isFile()) return h;
Properties p = new Properties();
try (FileInputStream in = new FileInputStream(FILE)) {
p.load(in);
} catch (Exception e) {
return h;
}
int count = parseInt(p.getProperty("count"), 0);
for (int i = 0; i < count; i++) {
String prefix = "hotkey." + i + ".";
HotkeyAction action = HotkeyAction.byName(p.getProperty(prefix + "action", ""));
if (action == null) continue;
Hotkey entry = new Hotkey(action, HotkeyCombo.parse(p.getProperty(prefix + "keys", "")));
entry.trigger = "KEY_UP".equals(p.getProperty(prefix + "trigger"))
? Hotkey.Trigger.KEY_UP : Hotkey.Trigger.KEY_DOWN;
entry.activeServerOnly = !"false".equals(p.getProperty(prefix + "activeServerOnly"));
entry.argument = p.getProperty(prefix + "argument", "");
entry.enabled = !"false".equals(p.getProperty(prefix + "enabled"));
if (entry.isValid()) h.entries.add(entry);
}
return h;
}
public void save() {
Properties p = new Properties();
synchronized (entries) {
p.setProperty("count", Integer.toString(entries.size()));
for (int i = 0; i < entries.size(); i++) {
Hotkey e = entries.get(i);
String prefix = "hotkey." + i + ".";
p.setProperty(prefix + "action", e.action.name());
p.setProperty(prefix + "keys", e.combo.toString());
p.setProperty(prefix + "trigger", e.trigger.name());
p.setProperty(prefix + "activeServerOnly", Boolean.toString(e.activeServerOnly));
p.setProperty(prefix + "argument", e.argument == null ? "" : e.argument);
p.setProperty(prefix + "enabled", Boolean.toString(e.enabled));
}
}
try {
if (!DIR.isDirectory()) {
//noinspection ResultOfMethodCallIgnored
DIR.mkdirs();
}
try (FileOutputStream out = new FileOutputStream(FILE)) {
p.store(out, "TS3J client hotkeys");
}
} catch (Exception ignored) {
}
}
private static int parseInt(String v, int def) {
if (v == null) return def;
try {
return Integer.parseInt(v.trim());
} catch (NumberFormatException e) {
return def;
}
}
}

View File

@@ -0,0 +1,275 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.api.Channel;
import com.github.manevolent.ts3j.command.MultiCommand;
import com.github.manevolent.ts3j.command.SingleCommand;
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent;
import com.github.manevolent.ts3j.protocol.ProtocolRole;
import com.ts3client.net.filetransfer.FileTransfer;
import com.ts3client.net.filetransfer.FileTransferManager;
import com.ts3client.net.filetransfer.RemoteFile;
import java.io.File;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.zip.CRC32;
/**
* Everything the channel editor needs of the server: reading and writing a channel's
* properties and its needed-power permissions, plus the virtual server's icon store.
*
* <p>Split out of {@link TeamspeakConnection} because it is a self-contained request/
* response corner of the protocol that only the editor uses. Every call here blocks and
* must run off the UI thread; the connection's public wrappers do that.
*/
final class ChannelAdmin {
/** The directory a server's icons live in, where the server has one. */
private static final String ICON_DIRECTORY = "icons";
/** How long to wait for a whole icon to travel over the file connection. */
private static final long ICON_UPLOAD_TIMEOUT_MS = 30_000;
private final TeamspeakConnection conn;
/**
* The {@code channelpermlist} reply in progress. TeamSpeak answers the command with a
* burst of {@code notifychannelpermlist} events rather than a command response, and
* only the first of them repeats the channel id, so one request may be in flight at a
* time and its entries are collected here until the command itself completes.
*/
private volatile PermRequest permRequest;
ChannelAdmin(TeamspeakConnection conn) {
this.conn = conn;
}
// ---- channel properties ----
/** Reads a channel's full property set. */
ChannelSettings readSettings(int channelId) throws Exception {
Channel info = conn.socket().getChannelInfo(channelId);
if (info == null) throw new IllegalStateException("The server did not describe that channel");
return ChannelSettings.from(channelId, info.getMap());
}
/**
* Creates a channel below {@code parentId} (0 being the top level).
*
* @param properties the new channel's properties, as {@link ChannelSettings#creationParameters}
* @return the new channel's id
*/
int create(int parentId, Map<String, String> properties) throws Exception {
SingleCommand cmd = new SingleCommand("channelcreate", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("cpid", Integer.toString(parentId)));
for (Map.Entry<String, String> property : properties.entrySet()) {
cmd.add(new CommandSingleParameter(property.getKey(), property.getValue()));
}
for (SingleCommand answer : conn.socket().executeCommand(cmd).get()) {
int cid = parseInt(answer.toMap().get("cid"), -1);
if (cid > 0) return cid;
}
// Some servers acknowledge the command without naming the channel; the
// notifychannelcreated they also send does name it, so read it off the model
// once that event has been handled.
conn.awaitEventsProcessed();
ChannelNode created = conn.getModel().findChannelByName(parentId, properties.get("channel_name"));
if (created == null) throw new IllegalStateException("The server did not report the new channel");
return created.id;
}
/** Applies {@code changes} (property name to value) with a single {@code channeledit}. */
void edit(int channelId, Map<String, String> changes) throws Exception {
if (changes.isEmpty()) return;
SingleCommand cmd = new SingleCommand("channeledit", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
for (Map.Entry<String, String> change : changes.entrySet()) {
cmd.add(new CommandSingleParameter(change.getKey(), change.getValue()));
}
conn.socket().executeCommand(cmd).complete();
}
// ---- channel permissions ----
/**
* Reads the permissions set directly on a channel.
*
* @return permission name to value, holding only the permissions the channel itself
* carries — anything inherited from a group is absent
*/
Map<String, Integer> readPermissions(int channelId) throws Exception {
if (!conn.getModel().hasPermissionNames()) {
// channelpermlist reports numeric ids, which only the permissionlist response
// gives names to; without it the values could not be told apart.
throw new IllegalStateException("The server did not send its permission list");
}
PermRequest request = new PermRequest(channelId);
permRequest = request;
try {
SingleCommand cmd = new SingleCommand("channelpermlist", ProtocolRole.CLIENT,
new CommandSingleParameter("cid", Integer.toString(channelId)));
conn.socket().executeCommand(cmd).complete();
// The entries were queued on the event thread before the command's own reply
// was read, so draining that thread is what guarantees they have all landed.
conn.awaitEventsProcessed();
} finally {
permRequest = null;
}
Map<String, Integer> byName = new LinkedHashMap<>();
for (Map.Entry<Integer, Integer> entry : request.values.entrySet()) {
String name = conn.getModel().permissionName(entry.getKey());
if (name != null) byName.put(name, entry.getValue());
}
return byName;
}
/** Collects one {@code notifychannelpermlist} entry for the request in flight. */
void onPermListEntry(UnknownTeamspeakEvent e) {
PermRequest request = permRequest;
if (request == null) return;
String cid = e.get("cid");
if (cid != null && !cid.isEmpty() && parseInt(cid, -1) != request.channelId) return;
int permId = parseInt(e.get("permid"), -1);
if (permId >= 0) request.values.put(permId, parseInt(e.get("permvalue"), 0));
}
/**
* Sets and clears channel permissions by name. A permission set to a value is added or
* updated; one that is cleared is removed so the channel inherits it again.
*/
void writePermissions(int channelId, Map<String, Integer> set, Collection<String> remove)
throws Exception {
if (!set.isEmpty()) {
List<SingleCommand> parts = new ArrayList<>(set.size());
for (Map.Entry<String, Integer> perm : set.entrySet()) {
parts.add(new SingleCommand("channeladdperm", ProtocolRole.CLIENT,
new CommandSingleParameter("permsid", perm.getKey()),
new CommandSingleParameter("permvalue", Integer.toString(perm.getValue()))));
}
send("channeladdperm", channelId, parts);
}
if (!remove.isEmpty()) {
List<SingleCommand> parts = new ArrayList<>(remove.size());
for (String name : remove) {
parts.add(new SingleCommand("channeldelperm", ProtocolRole.CLIENT,
new CommandSingleParameter("permsid", name)));
}
send("channeldelperm", channelId, parts);
}
}
private void send(String name, int channelId, List<SingleCommand> parts) throws Exception {
MultiCommand cmd = new MultiCommand(name, ProtocolRole.CLIENT, parts);
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
conn.socket().executeCommand(cmd).complete();
}
// ---- the server's icon store ----
/**
* The ids of the icons uploaded to this virtual server, which live in channel 0's file
* repository as {@code /icon_<id>}.
*/
List<Long> listIcons() throws Exception {
List<Long> ids = new ArrayList<>();
boolean hasIconDirectory = false;
for (RemoteFile file : fileTransfers().list(0, "", "/")) {
if (file.isDirectory()) {
hasIconDirectory |= file.getName().equals(ICON_DIRECTORY);
continue;
}
addIcon(ids, file);
}
// Newer servers keep the icons in their own directory rather than loose in the
// repository's root; both layouts exist, so whichever this server uses is read.
if (hasIconDirectory) {
for (RemoteFile file : fileTransfers().list(0, "", "/" + ICON_DIRECTORY)) {
if (!file.isDirectory()) addIcon(ids, file);
}
}
return ids;
}
private static void addIcon(List<Long> ids, RemoteFile file) {
if (!file.getName().startsWith("icon_")) return;
long id = parseLong(file.getName().substring("icon_".length()));
if (id > 0 && !ids.contains(id)) ids.add(id);
}
/**
* Uploads an image as a server icon.
*
* @return the icon's id, which TeamSpeak derives from the file's CRC32
*/
long uploadIcon(File source) throws Exception {
byte[] data = Files.readAllBytes(source.toPath());
if (data.length == 0) throw new IllegalArgumentException("That file is empty");
if (data.length > TeamspeakConnection.MAX_ICON_BYTES) {
throw new IllegalArgumentException("Icons may not be larger than "
+ (TeamspeakConnection.MAX_ICON_BYTES / 1024) + " KiB");
}
CRC32 crc = new CRC32();
crc.update(data);
long id = crc.getValue();
CountDownLatch done = new CountDownLatch(1);
FileTransfer transfer = fileTransfers().upload(0, "", "/icon_" + id, source, true, t -> {
if (t.isDone()) done.countDown();
});
if (!done.await(ICON_UPLOAD_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("The upload did not finish in time");
}
if (transfer.getState() != FileTransfer.State.COMPLETED) {
throw new IllegalStateException(transfer.getErrorMessage() == null
? "The upload failed" : transfer.getErrorMessage());
}
return id;
}
void deleteIcon(long iconId) throws Exception {
fileTransfers().delete(0, "", "/icon_" + iconId);
}
private FileTransferManager fileTransfers() {
FileTransferManager ft = conn.fileTransfers();
if (ft == null) throw new IllegalStateException("Not connected");
return ft;
}
// ---- helpers ----
private static int parseInt(String value, int fallback) {
try {
return value == null || value.isEmpty() ? fallback : Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
return fallback;
}
}
private static long parseLong(String value) {
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException e) {
return 0;
}
}
/** One in-flight {@code channelpermlist} and the entries received for it so far. */
private static final class PermRequest {
final int channelId;
final Map<Integer, Integer> values = new ConcurrentHashMap<>();
PermRequest(int channelId) {
this.channelId = channelId;
}
}
}

View File

@@ -17,6 +17,8 @@ public final class ChannelNode {
public int maxClients = -1; public int maxClients = -1;
/** Id of the channel's custom icon in the server's file repository, or 0 for none. */ /** Id of the channel's custom icon in the server's file repository, or 0 for none. */
public long iconId; public long iconId;
/** Whether the server sends us this channel's client list; set from the subscription events. */
public boolean subscribed;
/** Populated when the tree is rebuilt. */ /** Populated when the tree is rebuilt. */
public final List<ChannelNode> children = new ArrayList<>(); public final List<ChannelNode> children = new ArrayList<>();
@@ -26,4 +28,35 @@ public final class ChannelNode {
this.id = id; this.id = id;
this.name = name; this.name = name;
} }
/** @return whether this channel or any channel below it is subscribed */
public boolean anySubscribed() {
if (subscribed) return true;
for (ChannelNode child : children) {
if (child.anySubscribed()) return true;
}
return false;
}
/** @return whether this channel and every channel below it is subscribed */
public boolean allSubscribed() {
if (!subscribed) return false;
for (ChannelNode child : children) {
if (!child.allSubscribed()) return false;
}
return true;
}
/** @return this channel's id, followed by those of the channels below it when {@code family} */
public List<Integer> familyIds(boolean family) {
List<Integer> ids = new ArrayList<>();
collectIds(family, ids);
return ids;
}
private void collectIds(boolean family, List<Integer> into) {
into.add(id);
if (!family) return;
for (ChannelNode child : children) child.collectIds(true, into);
}
} }

View File

@@ -0,0 +1,216 @@
package com.ts3client.net;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* The editable properties of a channel, as read from {@code channelinfo} and written
* back with {@code channeledit}.
*
* <p>Kept apart from {@link ChannelNode} on purpose: the node is the live tree state the
* server pushes at us, while this is a snapshot an editor works on. Submitting compares
* the edited copy against the untouched original ({@link #changesFrom}) so only the
* properties the user actually changed are sent — TeamSpeak refuses the whole command
* when it contains one property the client may not modify, even an unchanged one.
*/
public final class ChannelSettings {
/** How long a channel outlives its last occupant. */
public enum Type {TEMPORARY, SEMI_PERMANENT, PERMANENT}
public int id;
public int parentId;
public String name = "";
public String topic = "";
public String description = "";
public String phoneticName = "";
/** Whether the server reports a password on this channel; the password itself is never readable. */
public boolean hasPassword;
/** {@code null} leaves the password alone, {@code ""} removes it, anything else sets it. */
public String password;
public Type type = Type.PERMANENT;
public boolean defaultChannel;
/** The sibling this channel sorts after, or 0 to sort first. */
public int order;
public int neededTalkPower;
/** TeamSpeak codec id; see {@code com.github.manevolent.ts3j.api.Codec}. */
public int codec = 4;
public int codecQuality = 6;
public boolean encrypted = true;
public int deleteDelay;
public boolean maxClientsUnlimited = true;
public int maxClients = 16;
public boolean familyInherited = true;
public boolean familyUnlimited;
public int maxFamilyClients = 16;
public long iconId;
/** Reads a {@code channelinfo} (or {@code channellist}) property map. */
public static ChannelSettings from(int channelId, Map<String, String> props) {
ChannelSettings s = new ChannelSettings();
s.id = channelId;
s.parentId = intOf(props, "pid", intOf(props, "cpid", 0));
s.name = strOf(props, "channel_name");
s.topic = strOf(props, "channel_topic");
s.description = strOf(props, "channel_description");
s.phoneticName = strOf(props, "channel_name_phonetic");
s.hasPassword = boolOf(props, "channel_flag_password");
if (boolOf(props, "channel_flag_permanent")) s.type = Type.PERMANENT;
else if (boolOf(props, "channel_flag_semi_permanent")) s.type = Type.SEMI_PERMANENT;
else s.type = Type.TEMPORARY;
s.defaultChannel = boolOf(props, "channel_flag_default");
s.order = intOf(props, "channel_order", 0);
s.neededTalkPower = intOf(props, "channel_needed_talk_power", 0);
s.codec = intOf(props, "channel_codec", 4);
s.codecQuality = intOf(props, "channel_codec_quality", 6);
s.encrypted = !boolOf(props, "channel_codec_is_unencrypted");
s.deleteDelay = intOf(props, "channel_delete_delay", 0);
s.maxClientsUnlimited = boolOf(props, "channel_flag_maxclients_unlimited");
s.maxClients = Math.max(0, intOf(props, "channel_maxclients", 16));
s.familyInherited = boolOf(props, "channel_flag_maxfamilyclients_inherited");
s.familyUnlimited = boolOf(props, "channel_flag_maxfamilyclients_unlimited");
s.maxFamilyClients = Math.max(0, intOf(props, "channel_maxfamilyclients", 16));
s.iconId = longOf(props, "channel_icon_id");
return s;
}
public ChannelSettings copy() {
ChannelSettings c = new ChannelSettings();
c.id = id;
c.parentId = parentId;
c.name = name;
c.topic = topic;
c.description = description;
c.phoneticName = phoneticName;
c.hasPassword = hasPassword;
c.password = password;
c.type = type;
c.defaultChannel = defaultChannel;
c.order = order;
c.neededTalkPower = neededTalkPower;
c.codec = codec;
c.codecQuality = codecQuality;
c.encrypted = encrypted;
c.deleteDelay = deleteDelay;
c.maxClientsUnlimited = maxClientsUnlimited;
c.maxClients = maxClients;
c.familyInherited = familyInherited;
c.familyUnlimited = familyUnlimited;
c.maxFamilyClients = maxFamilyClients;
c.iconId = iconId;
return c;
}
/**
* The {@code channeledit} parameters needed to turn {@code original} into this.
*
* @return property name to value, empty when nothing changed
*/
public Map<String, String> changesFrom(ChannelSettings original) {
Map<String, String> changes = new LinkedHashMap<>();
putIfChanged(changes, "channel_name", name, original.name);
putIfChanged(changes, "channel_topic", topic, original.topic);
putIfChanged(changes, "channel_description", description, original.description);
putIfChanged(changes, "channel_name_phonetic", phoneticName, original.phoneticName);
if (password != null) changes.put("channel_password", password);
if (type != original.type) {
// The three flags are mutually exclusive and the server wants the whole set:
// clearing one without setting another leaves the channel type ambiguous.
changes.put("channel_flag_permanent", flag(type == Type.PERMANENT));
changes.put("channel_flag_semi_permanent", flag(type == Type.SEMI_PERMANENT));
changes.put("channel_flag_temporary", flag(type == Type.TEMPORARY));
}
// Clearing the default flag is meaningless — some channel has to be the default —
// so only setting it is ever sent.
if (defaultChannel && !original.defaultChannel) changes.put("channel_flag_default", "1");
putIfChanged(changes, "channel_order", order, original.order);
putIfChanged(changes, "channel_needed_talk_power", neededTalkPower, original.neededTalkPower);
putIfChanged(changes, "channel_codec", codec, original.codec);
putIfChanged(changes, "channel_codec_quality", codecQuality, original.codecQuality);
putIfChanged(changes, "channel_codec_is_unencrypted", !encrypted, !original.encrypted);
putIfChanged(changes, "channel_delete_delay", deleteDelay, original.deleteDelay);
putIfChanged(changes, "channel_flag_maxclients_unlimited",
maxClientsUnlimited, original.maxClientsUnlimited);
if (!maxClientsUnlimited) putIfChanged(changes, "channel_maxclients", maxClients, original.maxClients);
putIfChanged(changes, "channel_flag_maxfamilyclients_inherited",
familyInherited, original.familyInherited);
putIfChanged(changes, "channel_flag_maxfamilyclients_unlimited",
familyUnlimited, original.familyUnlimited);
if (!familyInherited && !familyUnlimited) {
putIfChanged(changes, "channel_maxfamilyclients", maxFamilyClients, original.maxFamilyClients);
}
if (iconId != original.iconId) changes.put("channel_icon_id", Long.toString(iconId));
return changes;
}
/**
* The {@code channelcreate} parameters for a channel that does not exist yet.
*
* <p>Everything a fresh channel would get anyway is left out, because TeamSpeak checks
* a create permission per property that is present — a client allowed to create plain
* channels but not, say, ones with a topic must not send an empty topic. What the
* server cannot infer is always sent: the name, the channel type (whose absent flags
* would silently mean temporary), the codec, and the client limits when they are not
* unlimited.
*/
public Map<String, String> creationParameters() {
Map<String, String> params = changesFrom(new ChannelSettings());
params.put("channel_name", name);
params.put("channel_flag_permanent", flag(type == Type.PERMANENT));
params.put("channel_flag_semi_permanent", flag(type == Type.SEMI_PERMANENT));
params.put("channel_flag_temporary", flag(type == Type.TEMPORARY));
params.put("channel_codec", Integer.toString(codec));
params.put("channel_codec_quality", Integer.toString(codecQuality));
if (!maxClientsUnlimited) params.put("channel_maxclients", Integer.toString(maxClients));
if (!familyInherited && !familyUnlimited) {
params.put("channel_maxfamilyclients", Integer.toString(maxFamilyClients));
}
if (password == null || password.isEmpty()) params.remove("channel_password");
return params;
}
private static void putIfChanged(Map<String, String> into, String key, String value, String was) {
if (!value.equals(was)) into.put(key, value);
}
private static void putIfChanged(Map<String, String> into, String key, int value, int was) {
if (value != was) into.put(key, Integer.toString(value));
}
private static void putIfChanged(Map<String, String> into, String key, boolean value, boolean was) {
if (value != was) into.put(key, flag(value));
}
private static String flag(boolean set) {
return set ? "1" : "0";
}
private static String strOf(Map<String, String> props, String key) {
String v = props.get(key);
return v == null ? "" : v;
}
private static boolean boolOf(Map<String, String> props, String key) {
return "1".equals(props.get(key));
}
private static int intOf(Map<String, String> props, String key, int fallback) {
try {
String v = props.get(key);
return v == null || v.isEmpty() ? fallback : Integer.parseInt(v.trim());
} catch (NumberFormatException e) {
return fallback;
}
}
private static long longOf(Map<String, String> props, String key) {
try {
String v = props.get(key);
if (v == null || v.isEmpty()) return 0;
long id = Long.parseLong(v.trim());
return id < 0 ? id & 0xFFFFFFFFL : id;
} catch (NumberFormatException e) {
return 0;
}
}
}

View File

@@ -6,6 +6,7 @@ public final class ClientEntry {
public int channelId; public int channelId;
public String nickname; public String nickname;
public String uniqueId = ""; public String uniqueId = "";
public int databaseId;
public int type; // 0 = normal voice client, 1 = server-query public int type; // 0 = normal voice client, 1 = server-query
public int talkPower; public int talkPower;
@@ -21,7 +22,13 @@ public final class ClientEntry {
public boolean talking; public boolean talking;
public boolean inputMuted; // microphone muted (client_input_muted) public boolean inputMuted; // microphone muted (client_input_muted)
public boolean outputMuted; // speakers muted / deafened (client_output_muted) public boolean outputMuted; // speakers muted / deafened (client_output_muted)
/** False while the capture device is unavailable: another tab holds the microphone. */
public boolean inputHardware = true;
/** False while the playback device is unavailable. */
public boolean outputHardware = true;
public boolean away; public boolean away;
/** The message published with the away state, empty when there is none. */
public String awayMessage = "";
public boolean channelCommander; public boolean channelCommander;
public boolean self; public boolean self;
@@ -33,4 +40,28 @@ public final class ClientEntry {
public boolean isQuery() { public boolean isQuery() {
return type == 1; return type == 1;
} }
/** Adds a server group id, if not already present. */
public void addServerGroup(int groupId) {
for (int id : serverGroupIds) if (id == groupId) return;
int[] updated = java.util.Arrays.copyOf(serverGroupIds, serverGroupIds.length + 1);
updated[serverGroupIds.length] = groupId;
serverGroupIds = updated;
}
/** Removes a server group id, if present. */
public void removeServerGroup(int groupId) {
int index = -1;
for (int i = 0; i < serverGroupIds.length; i++) {
if (serverGroupIds[i] == groupId) {
index = i;
break;
}
}
if (index < 0) return;
int[] updated = new int[serverGroupIds.length - 1];
System.arraycopy(serverGroupIds, 0, updated, 0, index);
System.arraycopy(serverGroupIds, index + 1, updated, index, serverGroupIds.length - index - 1);
serverGroupIds = updated;
}
} }

View File

@@ -0,0 +1,629 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.event.*;
import com.ts3client.sound.SoundEvent;
import com.ts3client.sound.SoundNotifier;
import java.util.Map;
/**
* The {@link TS3Listener} side of a connection: keeps {@link ServerModel} in sync with
* incoming notify events, mirrors native TeamSpeak's server-tab log lines, and picks the
* matching {@link SoundEvent} for each one. Registered alongside {@link TeamspeakConnection}
* itself (which keeps only the lifecycle-critical {@code onDisconnected} callback) so this
* class can stay focused on "event arrived, update state and tell the user" and nothing else.
*/
final class ConnectionEventHandler implements TS3Listener {
// ---- who went where: TeamSpeak's reason ids ----
/** {@code reasonid} of a client view/move notification. */
private static final int REASON_SWITCHED = 0;
private static final int REASON_MOVED = 1;
private static final int REASON_TIMEOUT = 3;
private static final int REASON_CHANNEL_KICK = 4;
private static final int REASON_SERVER_KICK = 5;
private static final int REASON_BAN = 6;
private final TeamspeakConnection conn;
ConnectionEventHandler(TeamspeakConnection conn) {
this.conn = conn;
}
@Override
public void onClientJoin(ClientJoinEvent e) {
ClientEntry c = conn.getModel().putClient(e.getClientId(), e.getClientNickname(), e.getClientTargetId());
c.type = safeInt(e, "client_type");
c.talkPower = e.getClientTalkPower();
c.inputMuted = e.isClientInputMuted();
c.outputMuted = e.isClientOutputMuted();
c.inputHardware = e.isClientUsingHardwareInput();
c.outputHardware = e.isClientUsingHardwareOutput();
c.away = e.isClientAway();
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
c.databaseId = e.getClientDatabaseId();
c.serverGroupIds = parseIntList(e.getClientServerGroups());
c.channelGroupId = e.getClientChannelGroupId();
c.self = (e.getClientId() == conn.getSelfClientId());
if (e.getClientId() != conn.getSelfClientId()) {
announceClientEntered(e);
logClientEntered(e);
}
conn.ui.onModelChanged();
}
@Override
public void onClientLeave(ClientLeaveEvent e) {
if (e.getClientId() == conn.getSelfClientId()) {
announceOwnRemoval(safeInt(e, "reasonid"), e);
} else {
String name = conn.clientLink(e.getClientId());
announceClientLeft(e);
logClientLeft(e, name);
}
conn.getModel().removeClient(e.getClientId());
if (conn.getPlayback() != null) conn.getPlayback().removeClient(e.getClientId());
conn.ui.onModelChanged();
}
@Override
public void onClientMoved(ClientMovedEvent e) {
ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c != null) {
int from = c.channelId;
c.channelId = e.getTargetChannelId();
if (e.getClientId() == conn.getSelfClientId()) {
announceOwnMove(safeInt(e, "reasonid"), e);
} else {
announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
logClientMoved(e, conn.clientLink(c.id), from, e.getTargetChannelId());
}
conn.ui.onModelChanged();
}
}
/** A client became visible to us, logged the way native TS3's server tab does. */
private void logClientEntered(ClientJoinEvent e) {
String name = conn.clientLink(e.getClientId(),
TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier()), e.getClientNickname());
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
conn.log(name + " appears, coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
break;
case REASON_CHANNEL_KICK:
conn.log(name + " appears, was kicked from channel \"" + conn.channelLink(e.getClientFromId())
+ "\" by " + invokerName(e));
break;
case REASON_SWITCHED:
conn.log(name + " switched to channel \"" + conn.channelLink(e.getClientTargetId())
+ "\", coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
break;
default:
conn.log(name + " connected to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
}
}
/** A client stopped being visible to us. */
private void logClientLeft(ClientLeaveEvent e, String name) {
String reasonMsg = TeamspeakConnection.orEmpty(e.get("reasonmsg"));
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
switch (safeInt(e, "reasonid")) {
case REASON_TIMEOUT:
conn.log(name + " dropped (ping timeout)");
break;
case REASON_SERVER_KICK:
conn.log(name + " was kicked from the server by " + invokerName(e) + suffix);
break;
case REASON_BAN:
conn.log(name + " was banned from the server by " + invokerName(e) + suffix);
break;
case REASON_CHANNEL_KICK:
conn.log(name + " left: was kicked to channel \"" + conn.channelLink(e.getClientTargetId())
+ "\" by " + invokerName(e) + suffix);
break;
case REASON_MOVED:
conn.log(name + " left, heading to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
break;
case REASON_SWITCHED:
conn.log(name + " left, switched to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
break;
default:
conn.log(name + " disconnected");
}
}
/** A client we can see moved between two channels we can see. */
private void logClientMoved(ClientMovedEvent e, String name, int fromChannel, int toChannel) {
String reasonMsg = TeamspeakConnection.orEmpty(e.get("reasonmsg"));
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
conn.log(name + " was moved from channel \"" + conn.channelLink(fromChannel) + "\" to \""
+ conn.channelLink(toChannel) + "\" by " + invokerName(e));
break;
case REASON_CHANNEL_KICK:
conn.log(name + " was kicked from channel \"" + conn.channelLink(fromChannel) + "\" to \""
+ conn.channelLink(toChannel) + "\" by " + invokerName(e) + suffix);
break;
default:
conn.log(name + " switched from channel \"" + conn.channelLink(fromChannel) + "\" to \""
+ conn.channelLink(toChannel) + "\"");
}
}
/**
* A client became visible: either they just connected, or they moved in from a
* channel we could not see — TeamSpeak's "appears" case.
*/
private void announceClientEntered(ClientJoinEvent e) {
if (!conn.isConnected()) return;
int clientId = e.getClientId();
boolean current = conn.inOwnChannel(e.getClientTargetId());
Map<String, String> vars = conn.clientVars(clientId, e.getClientNickname());
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
conn.sound(current ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_APPEARS
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_APPEARS, vars);
break;
case REASON_CHANNEL_KICK:
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_APPEARS
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_APPEARS, vars);
break;
case REASON_SWITCHED:
conn.sound(current ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_APPEARS, vars);
break;
default:
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_CONNECTED_CURRENT_CHANNEL
: SoundEvent.CLIENT_CONNECTION_CONNECTED_SERVER, vars);
}
}
/** A client stopped being visible: they left the server, or moved out of sight. */
private void announceClientLeft(ClientLeaveEvent e) {
if (!conn.isConnected()) return;
int clientId = e.getClientId();
boolean current = conn.inOwnChannel(e.getClientFromId());
Map<String, String> vars = conn.clientVars(clientId, null);
switch (safeInt(e, "reasonid")) {
case REASON_TIMEOUT:
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_CURRENT_CHANNEL
: SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_SERVER, vars);
break;
case REASON_SERVER_KICK:
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_CURRENT_CHANNEL
: SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_SERVER, vars);
break;
case REASON_BAN:
conn.sound(current ? SoundEvent.CLIENT_WAS_BANNED_CURRENT_CHANNEL
: SoundEvent.CLIENT_WAS_BANNED_SERVER, vars);
break;
case REASON_CHANNEL_KICK:
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_DISAPPEARS
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_DISAPPEARS, vars);
break;
case REASON_MOVED:
conn.sound(current ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_DISAPPEARS
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
break;
case REASON_SWITCHED:
conn.sound(current ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_DISAPPEARS
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
break;
default:
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_DISCONNECTED_CURRENT_CHANNEL
: SoundEvent.CLIENT_CONNECTION_DISCONNECTED_SERVER, vars);
}
}
/** A client we can see moved between two channels we can see ("stays"). */
private void announceClientMoved(int reason, int clientId, int fromChannel, int toChannel) {
if (!conn.isConnected()) return;
boolean toCurrent = conn.inOwnChannel(toChannel);
boolean fromCurrent = conn.inOwnChannel(fromChannel);
Map<String, String> vars = conn.clientVars(clientId, null);
switch (reason) {
case REASON_MOVED:
conn.sound(toCurrent ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS
: fromCurrent ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_STAYS
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_STAYS, vars);
break;
case REASON_CHANNEL_KICK:
conn.sound(toCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_STAYS
: fromCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_STAYS
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_STAYS, vars);
break;
default:
conn.sound(toCurrent ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_STAYS
: fromCurrent ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_STAYS
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_STAYS, vars);
}
}
/** We changed channel ourselves, or somebody changed it for us. */
private void announceOwnMove(int reason, ClientMovedEvent e) {
if (!conn.isConnected()) return;
Map<String, String> vars = conn.channelVars(e.getTargetChannelId(), e.get("invokername"));
switch (reason) {
case REASON_MOVED:
conn.sound(SoundEvent.YOU_WERE_MOVED_TO_DIFFERENT_CHANNEL, vars);
break;
case REASON_CHANNEL_KICK:
conn.sound(SoundEvent.YOU_WERE_KICKED_FROM_CHANNEL, vars);
break;
default:
conn.sound(SoundEvent.YOU_SWITCHED_CHANNEL, vars);
}
}
/** We were removed from the server (kick or ban); the disconnect follows. */
private void announceOwnRemoval(int reason, ClientLeaveEvent e) {
if (!conn.isConnected()) return;
Map<String, String> vars = SoundNotifier.vars(
"servername", conn.getModel().getServerName(),
"clientname", TeamspeakConnection.orEmpty(e.get("invokername")),
"reason", TeamspeakConnection.orEmpty(e.get("reasonmsg")));
if (reason == REASON_SERVER_KICK) {
conn.sound(SoundEvent.YOU_WERE_KICKED_FROM_SERVER, vars);
} else if (reason == REASON_BAN) {
conn.sound(SoundEvent.YOU_WERE_BANNED, vars);
}
}
@Override
public void onClientChanged(ClientUpdatedEvent e) {
ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c == null) return;
boolean renamed = has(e, "client_nickname") && !e.get("client_nickname").equals(c.nickname);
String oldName = c.nickname;
if (renamed) c.nickname = e.get("client_nickname");
if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted");
if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted");
if (has(e, "client_input_hardware")) c.inputHardware = e.getBoolean("client_input_hardware");
if (has(e, "client_output_hardware")) c.outputHardware = e.getBoolean("client_output_hardware");
if (has(e, "client_away")) {
c.away = e.getBoolean("client_away");
// Both fields travel together, so an absent message here means "no message"
// — which `has` cannot tell from "not reported".
c.awayMessage = c.away ? TeamspeakConnection.orEmpty(e.get("client_away_message")) : "";
} else if (has(e, "client_away_message")) {
c.awayMessage = e.get("client_away_message");
}
if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power");
if (has(e, "client_description")) {
c.description = e.get("client_description");
conn.ui.onInfoUpdated();
}
if (has(e, "client_is_channel_commander"))
c.channelCommander = e.getBoolean("client_is_channel_commander");
announceClientUpdate(e, c, renamed, oldName);
conn.ui.onModelChanged();
}
/** Renames, talk-power changes and recording flags all arrive as a client update. */
private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed, String oldName) {
if (!conn.isConnected()) return;
boolean self = c.id == conn.getSelfClientId();
Map<String, String> vars = conn.clientVars(c.id, null);
if (renamed) {
conn.sound(safeInt(e, "invokerid") == conn.getSelfClientId()
? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars);
conn.log(oldName + " is now known as " + conn.clientLink(c.id));
}
if (safeInt(e, "client_talk_request") > 0 && !self) {
conn.sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
}
if (self && has(e, "client_is_talker")) {
conn.sound(e.getBoolean("client_is_talker")
? SoundEvent.YOU_WERE_GRANTED_TALK_POWER : SoundEvent.YOU_WERE_REVOKED_TALK_POWER, vars);
}
if (!self && has(e, "client_is_recording")) {
boolean recording = e.getBoolean("client_is_recording");
if (!recording) {
conn.sound(SoundEvent.CLIENT_RECORDING_STOP, vars);
} else {
conn.sound(conn.inOwnChannel(c.channelId)
? SoundEvent.CLIENT_RECORDING_IN_CHANNEL : SoundEvent.CLIENT_RECORDING_START, vars);
}
}
}
@Override
public void onChannelCreate(ChannelCreateEvent e) {
int cid = e.getChannelId();
String name = e.get("channel_name");
int pid = safeInt(e, "cpid");
if (pid == 0) pid = safeInt(e, "pid");
int order = safeInt(e, "channel_order");
ChannelNode node = conn.getModel().putChannel(cid, name, pid, order);
long icon = TeamspeakConnection.safeLong(e, "channel_icon_id");
if (icon != 0) node.iconId = icon;
conn.getModel().relinkChannel(cid, pid, order);
if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
SoundEvent.CHANNEL_CREATED_BY_OTHER), conn.channelVars(cid, e.get("invokername")));
conn.log("Channel \"" + conn.channelLink(cid) + "\" was created by " + invokerName(e));
}
conn.ui.onModelChanged();
}
@Override
public void onChannelDeleted(ChannelDeletedEvent e) {
int cid = e.getChannelId();
if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER,
SoundEvent.CHANNEL_DELETED_BY_SERVER), conn.channelVars(cid, e.get("invokername")));
conn.log("Channel \"" + conn.channelName(cid) + "\" was deleted by " + invokerName(e));
}
conn.getModel().removeChannel(cid);
conn.ui.onModelChanged();
}
@Override
public void onChannelEdit(ChannelEditedEvent e) {
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
if (ch != null) {
if (has(e, "channel_name")) ch.name = e.get("channel_name");
if (has(e, "channel_order")) ch.order = e.getInt("channel_order");
if (has(e, "channel_icon_id")) ch.iconId = TeamspeakConnection.safeLong(e, "channel_icon_id");
if (has(e, "channel_topic")) ch.topic = e.get("channel_topic");
if (conn.isConnected()) {
boolean current = conn.inOwnChannel(ch.id);
conn.sound(current
? byInvoker(e, SoundEvent.CHANNEL_EDITED_CURRENT_BY_YOU,
SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER, SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER)
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
conn.channelVars(ch.id, e.get("invokername")));
conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was edited by " + invokerName(e));
}
conn.ui.onModelChanged();
}
}
/**
* Descriptions never travel with the notify, so a cached one has to be re-fetched.
* Only channels whose description was already loaded are refreshed; the rest pick the
* new text up the first time they are looked at.
*/
@Override
public void onChannelDescriptionChanged(ChannelDescriptionEditedEvent e) {
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
if (ch == null || !ch.descriptionLoaded) return;
ch.descriptionLoaded = false;
conn.requestChannelInfo(ch.id);
}
@Override
public void onChannelMoved(ChannelMovedEvent e) {
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
if (ch != null) {
int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId;
int order = has(e, "order") ? e.getInt("order") : ch.order;
conn.getModel().relinkChannel(ch.id, parent, order);
if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER,
SoundEvent.CHANNEL_MOVED_BY_OTHER), conn.channelVars(ch.id, e.get("invokername")));
conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was moved by " + invokerName(e));
}
conn.ui.onModelChanged();
}
}
@Override
public void onChannelSubscribed(ChannelSubscribedEvent e) {
setSubscribed(safeInt(e, "cid"), true);
}
@Override
public void onChannelUnsubscribed(ChannelUnsubscribedEvent e) {
setSubscribed(safeInt(e, "cid"), false);
}
private void setSubscribed(int cid, boolean subscribed) {
ChannelNode ch = conn.getModel().getChannel(cid);
if (ch == null || ch.subscribed == subscribed) return;
ch.subscribed = subscribed;
conn.ui.onModelChanged();
}
@Override
public void onServerEdit(ServerEditedEvent e) {
if (has(e, "virtualserver_name")) conn.getModel().setServerName(e.get("virtualserver_name"));
if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER,
SoundEvent.SERVER_EDITED_BY_OTHER), conn.serverVars());
}
conn.ui.onModelChanged();
}
@Override
public void onServerGroupClientAdded(ServerGroupClientAddedEvent e) {
ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c != null) c.addServerGroup(e.getServerGroupId());
boolean self = e.getClientId() == conn.getSelfClientId();
conn.sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER,
SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER)
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER,
SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_SERVER),
groupVars(e.getClientId(), e.getName()));
conn.log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName()
+ "\" by " + invokerName(e) + ".");
conn.ui.onModelChanged();
}
@Override
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
int clientId = safeInt(e, "clid");
ClientEntry c = conn.getModel().getClient(clientId);
if (c != null) c.removeServerGroup(e.getServerGroupId());
boolean self = clientId == conn.getSelfClientId();
conn.sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER)
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER,
SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_SERVER),
groupVars(clientId, e.get("name")));
conn.log(clientLogName(clientId) + " was removed from server group \"" + TeamspeakConnection.orEmpty(e.get("name"))
+ "\" by " + invokerName(e) + ".");
conn.ui.onModelChanged();
}
@Override
public void onClientChannelGroupChanged(ClientChannelGroupChangedEvent e) {
ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c != null) c.channelGroupId = e.getChannelGroupId();
boolean self = e.getClientId() == conn.getSelfClientId();
String groupName = conn.getModel().channelGroupName(e.getChannelGroupId());
conn.sound(self
? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER,
SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_SERVER)
: byInvoker(e, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER,
SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_SERVER),
groupVars(e.getClientId(), groupName));
conn.log("Channel group \"" + TeamspeakConnection.orEmpty(groupName) + "\" was assigned to "
+ clientLogName(e.getClientId()) + " by " + invokerName(e) + ".");
conn.ui.onModelChanged();
}
/** A client's name for a log line, as a link, falling back to its id once it has left. */
private String clientLogName(int clientId) {
return conn.clientLink(clientId);
}
/** Picks the event variant matching who caused the change: us, another client, or the server. */
private SoundEvent byInvoker(BaseEvent e, SoundEvent byYou, SoundEvent byOther, SoundEvent byServer) {
int invoker = safeInt(e, "invokerid");
if (invoker == conn.getSelfClientId() && invoker != 0) return byYou;
return invoker == 0 ? byServer : byOther;
}
private Map<String, String> groupVars(int clientId, String groupName) {
Map<String, String> vars = conn.clientVars(clientId, null);
vars.put("groupname", TeamspeakConnection.orEmpty(groupName));
return vars;
}
@Override
public void onChannelList(ChannelListEvent e) {
// Incremental channel arriving during connect.
int cid = e.getChannelId();
String name = e.get("channel_name");
int pid = safeInt(e, "cpid");
int order = safeInt(e, "channel_order");
ChannelNode node = conn.getModel().putChannel(cid, name, pid, order);
long icon = TeamspeakConnection.safeLong(e, "channel_icon_id");
if (icon != 0) node.iconId = icon;
}
@Override
public void onServerGroupList(ServerGroupListEvent e) {
int id = safeInt(e, "sgid");
if (id > 0) conn.getModel().putServerGroup(toGroup(e, id));
}
@Override
public void onChannelGroupList(ChannelGroupListEvent e) {
int id = safeInt(e, "cgid");
if (id > 0) conn.getModel().putChannelGroup(toGroup(e, id));
}
private static Group toGroup(BaseEvent e, int id) {
return new Group(id, e.get("name"), TeamspeakConnection.safeLong(e, "iconid"), safeInt(e, "sortid"),
safeInt(e, "type"), safeInt(e, "n_member_addp"), safeInt(e, "n_member_removep"));
}
@Override
public void onPermissionList(PermissionListEvent e) {
conn.getModel().putPermissionName(e.get("permname"));
}
@Override
public void onClientNeededPermissions(ClientNeededPermissionsEvent e) {
conn.getModel().putSelfPermissionValue(safeInt(e, "permid"), safeInt(e, "permvalue"));
}
@Override
public void onTextMessage(TextMessageEvent e) {
if (e.getInvokerId() == conn.getSelfClientId()) return; // don't echo our own
ConnectionListener.ChatScope scope;
SoundEvent notification;
switch (e.getTargetMode()) {
case CLIENT:
scope = ConnectionListener.ChatScope.PRIVATE;
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CLIENT;
break;
case CHANNEL:
scope = ConnectionListener.ChatScope.CHANNEL;
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL;
break;
default:
scope = ConnectionListener.ChatScope.SERVER;
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_SERVER;
break;
}
conn.sound(notification, conn.clientVars(e.getInvokerId(), e.getInvokerName()));
conn.ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage());
}
@Override
public void onClientPoke(ClientPokeEvent e) {
conn.sound(SoundEvent.OTHER_RECEIVED_POKE, conn.clientVars(e.getInvokerId(), e.getInvokerName()));
conn.ui.onPoke(TeamspeakConnection.orEmpty(e.getInvokerName()), TeamspeakConnection.orEmpty(e.get("msg")));
}
@Override
public void onUnknownEvent(UnknownTeamspeakEvent e) {
if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e);
else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e);
}
// ---- helpers ----
/** Who caused an event, linked where the server named them; "the server" when nobody did. */
private String invokerName(BaseEvent e) {
String name = TeamspeakConnection.orEmpty(e.get("invokername"));
if (name.isEmpty()) return "the server";
return conn.clientLink(safeInt(e, "invokerid"),
TeamspeakConnection.orEmpty(e.get("invokeruid")), name);
}
private static int safeInt(BaseEvent e, String key) {
try {
String v = e.get(key);
return v == null ? 0 : Integer.parseInt(v.trim());
} catch (Exception ex) {
return 0;
}
}
/**
* Whether the event actually carries a field. ts3j answers a missing key with an
* empty string rather than null, so a plain null check is always true — and a
* partial update (say, someone muting) would otherwise look like it reported
* every other field as well.
*/
private static boolean has(BaseEvent e, String key) {
String value = e.get(key);
return value != null && !value.isEmpty();
}
/** Parses a comma-separated id list (e.g. server groups "6,12,15"). */
private static int[] parseIntList(String csv) {
if (csv == null || csv.isEmpty()) return new int[0];
String[] parts = csv.split(",");
int[] out = new int[parts.length];
int n = 0;
for (String p : parts) {
try {
out[n++] = Integer.parseInt(p.trim());
} catch (NumberFormatException ignored) {
}
}
return n == parts.length ? out : java.util.Arrays.copyOf(out, n);
}
}

View File

@@ -33,4 +33,11 @@ public interface ConnectionListener {
/** Someone poked the local client. */ /** Someone poked the local client. */
void onPoke(String fromName, String message); void onPoke(String fromName, String message);
/**
* Something happened on the server that native TS3 records in the server
* log: a client connecting/disconnecting/moving, a channel being edited,
* a group being (un)assigned, and so on.
*/
void onServerLog(String message);
} }

View File

@@ -0,0 +1,300 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.api.Client;
import com.github.manevolent.ts3j.command.SingleCommand;
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent;
import com.github.manevolent.ts3j.protocol.PacketKind;
import com.github.manevolent.ts3j.protocol.ProtocolRole;
import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics;
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
import com.github.manevolent.ts3j.util.Pair;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
/**
* Builds {@link ConnectionStats} snapshots for the info dialog. The local client's own
* figures are read straight from its live packet counters; a remote client's come from
* {@code getconnectioninfo}, whose {@code notifyconnectioninfo} report arrives
* asynchronously via {@link #onReport} and is matched back up by client id.
*/
final class ConnectionStatsCollector {
private final TeamspeakConnection conn;
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
private final Map<Integer, PendingConnInfo> pending = new ConcurrentHashMap<>();
ConnectionStatsCollector(TeamspeakConnection conn) {
this.conn = conn;
}
/** Drops any requests left over from a connection that just went away. */
void clear() {
pending.clear();
}
/**
* Fetches connection statistics for a client and delivers them to
* {@code callback} (invoked off the Swing EDT — the callback is responsible
* for marshalling). May be called repeatedly to poll.
*/
void request(int clientId, Consumer<ConnectionStats> callback) {
if (clientId == conn.getSelfClientId()) {
new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start();
} else {
new Thread(() -> requestRemote(clientId, callback), "ts3j-conninfo").start();
}
}
/** Matches an asynchronously arriving {@code notifyconnectioninfo} report to its request. */
void onReport(UnknownTeamspeakEvent e) {
int clid = safeInt(e.get("clid"));
PendingConnInfo p = pending.remove(clid);
if (p == null) return;
applyConnectionFields(p.stats, e.getMap());
p.callback.accept(p.stats);
}
/** Builds a live snapshot of the local client's connection from its own counters. */
private ConnectionStats buildLocalStats() {
ConnectionStats s = new ConnectionStats();
s.clientId = conn.getSelfClientId();
s.self = true;
s.live = true;
s.packetLoss = 0; // the local client has no server->client loss figure
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
if (self != null) {
s.nickname = self.nickname;
s.version = self.version;
s.platform = self.platform;
s.idleTimeMs = self.idleTimeMs;
}
long connectedAt = conn.connectedAtMs;
if (connectedAt > 0) s.connectedTimeMs = System.currentTimeMillis() - connectedAt;
LocalTeamspeakClientSocket c = conn.client;
if (c == null) return s;
try {
Pair<Double, Double> ping = c.getPing();
s.pingMs = ping.getKey() * 1000.0;
s.pingDeviationMs = ping.getValue() * 1000.0;
} catch (Exception ignored) {
// ping unavailable; leave as unknown
}
long pSent = 0, pRecv = 0, bSent = 0, bRecv = 0;
long bwSs = 0, bwRs = 0, bwSm = 0, bwRm = 0;
for (PacketKind kind : PacketKind.values()) {
PacketStatistics st = c.getStatistics(kind);
ConnectionStats.KindStats ks = new ConnectionStats.KindStats(mapKind(kind));
ks.packetLoss = 0; // the local client cannot measure its own server->client loss
ks.packetsSent = st.getSentPackets();
ks.packetsReceived = st.getReceivedPackets();
ks.bytesSent = st.getSentBytes();
ks.bytesReceived = st.getReceivedBytes();
ks.bandwidthSentLastSecond = st.getSentBytesLastSecond();
ks.bandwidthReceivedLastSecond = st.getReceivedBytesLastSecond();
ks.bandwidthSentLastMinute = st.getSentBytesLastMinute();
ks.bandwidthReceivedLastMinute = st.getReceivedBytesLastMinute();
s.perKind.add(ks);
pSent += ks.packetsSent;
pRecv += ks.packetsReceived;
bSent += ks.bytesSent;
bRecv += ks.bytesReceived;
bwSs += ks.bandwidthSentLastSecond;
bwRs += ks.bandwidthReceivedLastSecond;
bwSm += ks.bandwidthSentLastMinute;
bwRm += ks.bandwidthReceivedLastMinute;
}
s.packetsSentTotal = pSent;
s.packetsReceivedTotal = pRecv;
s.bytesSentTotal = bSent;
s.bytesReceivedTotal = bRecv;
s.bandwidthSentLastSecond = bwSs;
s.bandwidthReceivedLastSecond = bwRs;
s.bandwidthSentLastMinute = bwSm;
s.bandwidthReceivedLastMinute = bwRm;
return s;
}
/**
* Requests a remote client's connection info. First loads {@code clientinfo}
* for the stable fields, then issues {@code getconnectioninfo} whose
* {@code notifyconnectioninfo} report arrives asynchronously via
* {@link #onReport}. If the report does not arrive shortly (e.g. the
* server withholds it), the clientinfo-only snapshot is delivered instead.
*/
private void requestRemote(int clientId, Consumer<ConnectionStats> callback) {
ConnectionStats s = new ConnectionStats();
s.clientId = clientId;
ClientEntry entry = conn.getModel().getClient(clientId);
if (entry != null) s.nickname = entry.nickname;
LocalTeamspeakClientSocket client = conn.client;
try {
Client c = client.getClientInfo(clientId);
if (c != null) {
s.version = TeamspeakConnection.orEmpty(c.getVersion());
s.platform = TeamspeakConnection.orEmpty(c.getPlatform());
s.ip = TeamspeakConnection.orEmpty(c.getIp());
s.idleTimeMs = c.getIdleTime();
if (entry == null) s.nickname = TeamspeakConnection.orEmpty(c.getNickname());
applyConnectionFields(s, c.getMap());
}
} catch (Exception ignored) {
// clientinfo may be permission-restricted; continue with what we have
}
PendingConnInfo p = new PendingConnInfo(callback, s);
pending.put(clientId, p);
boolean sent = false;
try {
SingleCommand cmd = new SingleCommand("getconnectioninfo", ProtocolRole.CLIENT,
new CommandSingleParameter("clid", Integer.toString(clientId)));
client.executeCommand(cmd).complete();
sent = true;
} catch (Exception ignored) {
// command failed; fall back to the clientinfo snapshot below
}
if (sent) {
try {
Thread.sleep(700); // give notifyconnectioninfo a chance to arrive
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
// If the report already arrived, onReport removed and delivered it.
if (pending.remove(clientId, p)) {
callback.accept(s);
}
}
/** Parses TeamSpeak {@code connection_*} fields from a command map into {@code s}. */
private static void applyConnectionFields(ConnectionStats s, Map<String, String> m) {
double ping = parseDouble(m.get("connection_ping"));
if (ping >= 0) s.pingMs = ping;
double dev = parseDouble(m.get("connection_ping_deviation"));
if (dev >= 0) s.pingDeviationMs = dev;
double loss = parseDouble(m.get("connection_packetloss_total"));
if (loss < 0) loss = parseDouble(m.get("connection_server2client_packetloss_total"));
if (loss >= 0) s.packetLoss = loss;
long connected = parseLong(m.get("connection_connected_time"));
if (connected >= 0) s.connectedTimeMs = connected;
String ip = m.get("connection_client_ip");
if (ip != null && !ip.isEmpty()) s.ip = ip;
s.packetsSentTotal = pick(s.packetsSentTotal, m.get("connection_packets_sent_total"));
s.packetsReceivedTotal = pick(s.packetsReceivedTotal, m.get("connection_packets_received_total"));
s.bytesSentTotal = pick(s.bytesSentTotal, m.get("connection_bytes_sent_total"));
s.bytesReceivedTotal = pick(s.bytesReceivedTotal, m.get("connection_bytes_received_total"));
s.bandwidthSentLastSecond =
pick(s.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_total"));
s.bandwidthReceivedLastSecond =
pick(s.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_total"));
s.bandwidthSentLastMinute =
pick(s.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_total"));
s.bandwidthReceivedLastMinute =
pick(s.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_total"));
s.filetransferBandwidthSent =
pick(s.filetransferBandwidthSent, m.get("connection_filetransfer_bandwidth_sent"));
s.filetransferBandwidthReceived =
pick(s.filetransferBandwidthReceived, m.get("connection_filetransfer_bandwidth_received"));
applyPerKindFields(s, m);
}
/**
* Parses the per-category {@code connection_*_<kind>} fields (as sent in a
* {@code notifyconnectioninfo} report) into {@code s}, merging into any
* existing rows. Categories absent from the map are left untouched.
*/
private static void applyPerKindFields(ConnectionStats s, Map<String, String> m) {
for (ConnectionStats.Kind kind : ConnectionStats.Kind.values()) {
String suffix = kind.name().toLowerCase(java.util.Locale.ROOT); // keepalive/control/speech
String probe = m.get("connection_packets_sent_" + suffix);
String probe2 = m.get("connection_server2client_packetloss_" + suffix);
if (probe == null && probe2 == null) continue; // this category not reported
ConnectionStats.KindStats ks = s.getOrCreateKind(kind);
ks.packetsSent = pick(ks.packetsSent, m.get("connection_packets_sent_" + suffix));
ks.packetsReceived = pick(ks.packetsReceived, m.get("connection_packets_received_" + suffix));
ks.bytesSent = pick(ks.bytesSent, m.get("connection_bytes_sent_" + suffix));
ks.bytesReceived = pick(ks.bytesReceived, m.get("connection_bytes_received_" + suffix));
ks.bandwidthSentLastSecond =
pick(ks.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_" + suffix));
ks.bandwidthReceivedLastSecond =
pick(ks.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_" + suffix));
ks.bandwidthSentLastMinute =
pick(ks.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_" + suffix));
ks.bandwidthReceivedLastMinute =
pick(ks.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_" + suffix));
double kloss = parseDouble(m.get("connection_server2client_packetloss_" + suffix));
if (kloss >= 0) ks.packetLoss = kloss;
}
}
private static long pick(long current, String value) {
long v = parseLong(value);
return v >= 0 ? v : current;
}
private static ConnectionStats.Kind mapKind(PacketKind kind) {
switch (kind) {
case KEEPALIVE:
return ConnectionStats.Kind.KEEPALIVE;
case SPEECH:
return ConnectionStats.Kind.SPEECH;
default:
return ConnectionStats.Kind.CONTROL;
}
}
/** Parses a long, returning -1 for null/blank/non-numeric input. */
private static long parseLong(String s) {
if (s == null || s.isEmpty()) return -1;
try {
return Long.parseLong(s.trim());
} catch (NumberFormatException e) {
return -1;
}
}
/** Parses a double, returning -1 for null/blank/non-numeric input. */
private static double parseDouble(String s) {
if (s == null || s.isEmpty()) return -1;
try {
return Double.parseDouble(s.trim());
} catch (NumberFormatException e) {
return -1;
}
}
/** @return the parsed value, or 0 when it is absent or not a number */
private static int safeInt(String value) {
try {
return value == null ? 0 : Integer.parseInt(value.trim());
} catch (NumberFormatException ex) {
return 0;
}
}
/** Callback + accumulating snapshot for an in-flight {@code getconnectioninfo}. */
private static final class PendingConnInfo {
final Consumer<ConnectionStats> callback;
final ConnectionStats stats;
PendingConnInfo(Consumer<ConnectionStats> callback, ConnectionStats stats) {
this.callback = callback;
this.stats = stats;
}
}
}

View File

@@ -9,11 +9,26 @@ public final class Group {
public final long iconId; public final long iconId;
/** Display order among groups; lower comes first. */ /** Display order among groups; lower comes first. */
public final int sortId; public final int sortId;
/** TS3 group type: 0 = template, 1 = regular, 2 = query (server groups only). */
public final int type;
/** Power needed to add a member to this group. */
public final int neededMemberAddPower;
/** Power needed to remove a member from this group. */
public final int neededMemberRemovePower;
public Group(int id, String name, long iconId, int sortId) { public Group(int id, String name, long iconId, int sortId, int type,
int neededMemberAddPower, int neededMemberRemovePower) {
this.id = id; this.id = id;
this.name = name; this.name = name;
this.iconId = iconId; this.iconId = iconId;
this.sortId = sortId; this.sortId = sortId;
this.type = type;
this.neededMemberAddPower = neededMemberAddPower;
this.neededMemberRemovePower = neededMemberRemovePower;
}
/** Regular, user-assignable groups exclude templates and query-only groups. */
public boolean isRegular() {
return type == 1;
} }
} }

View File

@@ -6,6 +6,7 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -29,6 +30,12 @@ public final class IconRepository {
/** Icons at or below this id are bundled defaults rather than server uploads. */ /** Icons at or below this id are bundled defaults rather than server uploads. */
public static final int MAX_BUNDLED_ID = 1000; public static final int MAX_BUNDLED_ID = 1000;
/**
* The default group icons shipped with the client, in the order TeamSpeak's own icon
* packs list them. These are what the icon chooser offers as "local" icons.
*/
public static final List<Long> BUNDLED_IDS = List.of(100L, 200L, 300L, 500L, 600L);
/** Downloads {@code /icon_<id>} from the connected server's file repository. */ /** Downloads {@code /icon_<id>} from the connected server's file repository. */
public interface Fetcher { public interface Fetcher {
byte[] fetchIcon(long iconId) throws Exception; byte[] fetchIcon(long iconId) throws Exception;

View File

@@ -18,13 +18,29 @@ public final class ServerModel {
private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>(); private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>();
private final Map<Integer, Group> serverGroups = new LinkedHashMap<>(); private final Map<Integer, Group> serverGroups = new LinkedHashMap<>();
private final Map<Integer, Group> channelGroups = new LinkedHashMap<>(); private final Map<Integer, Group> channelGroups = new LinkedHashMap<>();
/**
* Permission id -> name, learned from the server's {@code permissionlist} response.
* Entries carry no id of their own: TS3 numbers them by position in the response,
* and the empty "group_id_end" separator records marking category boundaries don't
* count toward that position, so only {@link #putPermissionName} advances it.
*/
private final Map<Integer, String> permissionNames = new LinkedHashMap<>();
private int nextPermissionId;
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
private String serverName = "TeamSpeak Server"; private String serverName = "TeamSpeak Server";
/** The channel group everyone starts in, e.g. "Guest" — not worth offering to (re)assign. */
private int defaultChannelGroupId;
public synchronized void clear() { public synchronized void clear() {
channels.clear(); channels.clear();
clients.clear(); clients.clear();
serverGroups.clear(); serverGroups.clear();
channelGroups.clear(); channelGroups.clear();
permissionNames.clear();
nextPermissionId = 0;
selfPermissionValues.clear();
defaultChannelGroupId = 0;
} }
// ---- groups ---- // ---- groups ----
@@ -78,6 +94,64 @@ public final class ServerModel {
return g == null ? null : g.name; return g == null ? null : g.name;
} }
/** Regular (non-template, non-query) server groups, ordered for display. */
public synchronized List<Group> allServerGroups() {
return regularGroups(serverGroups);
}
/** Regular (non-template, non-query) channel groups, ordered for display. */
public synchronized List<Group> allChannelGroups() {
return regularGroups(channelGroups);
}
private static List<Group> regularGroups(Map<Integer, Group> groups) {
List<Group> list = new ArrayList<>();
for (Group g : groups.values()) {
if (g.isRegular()) list.add(g);
}
list.sort(Comparator.comparingInt((Group g) -> g.sortId).thenComparingInt(g -> g.id));
return list;
}
// ---- permissions ----
/** Records the next named entry of a {@code permissionlist} response; ignores separators. */
public synchronized void putPermissionName(String name) {
if (name == null || name.isEmpty()) return;
permissionNames.put(nextPermissionId, name);
nextPermissionId++;
}
public synchronized void putSelfPermissionValue(int permId, int value) {
selfPermissionValues.put(permId, value);
}
/**
* The local client's resolved value for a named permission (e.g.
* {@code "i_group_needed_member_add_power"}), or {@code 0} if it isn't known
* yet (the {@code permissionlist} request is still in flight, or the server
* never reported a non-default value for it).
*/
/** Whether the server's {@code permissionlist} has arrived, so ids can be named at all. */
public synchronized boolean hasPermissionNames() {
return !permissionNames.isEmpty();
}
/** The name the server gave permission id {@code id}, or {@code null} if unknown. */
public synchronized String permissionName(int id) {
return permissionNames.get(id);
}
public synchronized int selfPermissionValue(String name) {
for (Map.Entry<Integer, String> entry : permissionNames.entrySet()) {
if (entry.getValue().equals(name)) {
Integer value = selfPermissionValues.get(entry.getKey());
if (value != null) return value;
}
}
return 0;
}
public synchronized String getServerName() { public synchronized String getServerName() {
return serverName; return serverName;
} }
@@ -86,6 +160,14 @@ public final class ServerModel {
if (name != null && !name.isEmpty()) this.serverName = name; if (name != null && !name.isEmpty()) this.serverName = name;
} }
public synchronized int defaultChannelGroupId() {
return defaultChannelGroupId;
}
public synchronized void setDefaultChannelGroupId(int id) {
this.defaultChannelGroupId = id;
}
// ---- channels ---- // ---- channels ----
public synchronized ChannelNode putChannel(int id, String name, int parentId, int order) { public synchronized ChannelNode putChannel(int id, String name, int parentId, int order) {
@@ -129,6 +211,41 @@ public final class ServerModel {
c.order = newOrder; c.order = newOrder;
} }
/**
* The channels sharing a parent with {@code channelId}, in the order they appear in the
* tree and excluding the channel itself — what the editor offers as places to sort after.
*/
public synchronized List<ChannelNode> siblingsOf(int channelId) {
ChannelNode channel = channels.get(channelId);
if (channel == null) return new ArrayList<>();
// Ordered with the channel still in place: the order links form a chain, and
// pulling a link out of it first would strand everything below.
List<ChannelNode> siblings = childrenOf(channel.parentId);
siblings.remove(channel);
return siblings;
}
/**
* The channels directly below {@code parentId} (0 being the top level), in the order
* they appear in the tree — what a new channel can be sorted after.
*/
public synchronized List<ChannelNode> childrenOf(int parentId) {
List<ChannelNode> children = new ArrayList<>();
for (ChannelNode c : channels.values()) {
if (c.parentId == parentId) children.add(c);
}
sortSiblings(children);
return children;
}
/** The channel of that exact name directly below {@code parentId}, or {@code null}. */
public synchronized ChannelNode findChannelByName(int parentId, String name) {
for (ChannelNode c : channels.values()) {
if (c.parentId == parentId && c.name.equals(name)) return c;
}
return null;
}
/** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */ /** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */
public synchronized String channelPath(int id) { public synchronized String channelPath(int id) {
ChannelNode c = channels.get(id); ChannelNode c = channels.get(id);

View File

@@ -52,6 +52,8 @@ public final class FileTransferManager {
/** TeamSpeak error id returned by {@code ftgetfilelist} for an empty directory. */ /** TeamSpeak error id returned by {@code ftgetfilelist} for an empty directory. */
private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501; private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501;
/** How long to wait for the server to finish listing a directory. */
private static final long LIST_TIMEOUT_MS = 15_000;
private final LocalTeamspeakClientSocket socket; private final LocalTeamspeakClientSocket socket;
/** Host we are connected to, used when the server reports no dedicated file-transfer host. */ /** Host we are connected to, used when the server reports no dedicated file-transfer host. */
@@ -70,6 +72,14 @@ public final class FileTransferManager {
* routes back to the waiting transfer thread. * routes back to the waiting transfer thread.
*/ */
private final Map<Integer, CompletableFuture<Map<String, String>>> pendingInits = new ConcurrentHashMap<>(); private final Map<Integer, CompletableFuture<Map<String, String>>> pendingInits = new ConcurrentHashMap<>();
/**
* The {@code ftgetfilelist} in progress. The server answers that command with a
* {@code notifyfilelist} event per entry and a closing {@code notifyfilelistfinished}
* rather than with rows of the command's own reply, so the entries are collected here
* until the listing ends. One listing may be in flight at a time.
*/
private volatile FileListRequest fileListRequest;
private final TS3Listener ftEventListener = new TS3Listener() { private final TS3Listener ftEventListener = new TS3Listener() {
@Override @Override
public void onUnknownEvent(UnknownTeamspeakEvent e) { public void onUnknownEvent(UnknownTeamspeakEvent e) {
@@ -88,34 +98,49 @@ public final class FileTransferManager {
/** /**
* Lists the files and subdirectories directly under {@code path} in the given * Lists the files and subdirectories directly under {@code path} in the given
* channel's repository. Returns an empty list for an empty directory. * channel's repository. Returns an empty list for an empty directory.
*
* <p>Blocks until the server has sent the whole listing, so it must not be called from
* the event thread that delivers it.
*/ */
public List<RemoteFile> list(int channelId, String channelPassword, String path) public List<RemoteFile> list(int channelId, String channelPassword, String path)
throws Exception { throws Exception {
String directory = path == null || path.isEmpty() ? "/" : path;
SingleCommand cmd = new SingleCommand("ftgetfilelist", ProtocolRole.CLIENT, SingleCommand cmd = new SingleCommand("ftgetfilelist", ProtocolRole.CLIENT,
new CommandSingleParameter("cid", Integer.toString(channelId)), new CommandSingleParameter("cid", Integer.toString(channelId)),
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword), new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
new CommandSingleParameter("path", path == null || path.isEmpty() ? "/" : path)); new CommandSingleParameter("path", directory));
List<RemoteFile> files = new ArrayList<>(); FileListRequest request = new FileListRequest(channelId, directory);
Iterable<SingleCommand> rows; fileListRequest = request;
try { try {
rows = socket.executeCommand(cmd).get(); socket.executeCommand(cmd).complete();
// The command's reply only acknowledges it; the entries are events, and the
// server marks their end with notifyfilelistfinished.
request.finished.get(LIST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
} catch (com.github.manevolent.ts3j.command.CommandException e) { } catch (com.github.manevolent.ts3j.command.CommandException e) {
if (e.getErrorId() == ERROR_DATABASE_EMPTY_RESULT) return files; if (e.getErrorId() != ERROR_DATABASE_EMPTY_RESULT) throw e;
throw e; } catch (TimeoutException e) {
throw new IOException("The server did not finish listing " + directory);
} finally {
fileListRequest = null;
} }
for (SingleCommand row : rows) { return new ArrayList<>(request.files);
Map<String, String> m = row.toMap(); }
String name = m.get("name");
if (name == null || name.isEmpty()) continue; /** Collects one {@code notifyfilelist} entry for the listing in flight. */
files.add(new RemoteFile( private void collectListEntry(Map<String, String> row) {
name, FileListRequest request = fileListRequest;
m.getOrDefault("path", path), if (request == null) return;
parseLong(m.get("size")), String cid = row.get("cid");
"0".equals(m.get("type")), if (cid != null && !cid.isEmpty() && parseLong(cid) != request.channelId) return;
parseLong(m.get("datetime")))); String name = row.get("name");
} if (name == null || name.isEmpty()) return;
return files; request.files.add(new RemoteFile(
name,
row.getOrDefault("path", request.path),
parseLong(row.get("size")),
"0".equals(row.get("type")),
parseLong(row.get("datetime"))));
} }
/** Creates a new directory at {@code dirPath} (a full path such as {@code /new}). */ /** Creates a new directory at {@code dirPath} (a full path such as {@code /new}). */
@@ -351,6 +376,15 @@ public final class FileTransferManager {
String command = e.getCommand(); String command = e.getCommand();
if (command == null) return; if (command == null) return;
Map<String, String> map = e.getMap(); Map<String, String> map = e.getMap();
if (command.equals("notifyfilelist")) {
collectListEntry(map);
return;
}
if (command.equals("notifyfilelistfinished")) {
FileListRequest request = fileListRequest;
if (request != null) request.finished.complete(null);
return;
}
Integer ftfid = tryParseInt(map.get("clientftfid")); Integer ftfid = tryParseInt(map.get("clientftfid"));
if (ftfid == null) return; if (ftfid == null) return;
CompletableFuture<Map<String, String>> future = pendingInits.get(ftfid); CompletableFuture<Map<String, String>> future = pendingInits.get(ftfid);
@@ -454,4 +488,17 @@ public final class FileTransferManager {
String m = r.getMessage(); String m = r.getMessage();
return m != null ? m : r.getClass().getSimpleName(); return m != null ? m : r.getClass().getSimpleName();
} }
/** One in-flight {@code ftgetfilelist} and the entries received for it so far. */
private static final class FileListRequest {
final int channelId;
final String path;
final List<RemoteFile> files = new java.util.concurrent.CopyOnWriteArrayList<>();
final CompletableFuture<Void> finished = new CompletableFuture<>();
FileListRequest(int channelId, String path) {
this.channelId = channelId;
this.path = path;
}
}
} }

View File

@@ -24,6 +24,7 @@ public final class SoundNotifier {
private volatile SoundPlayer player; private volatile SoundPlayer player;
private volatile SoundPack pack; private volatile SoundPack pack;
private volatile List<SoundPack> available = List.of(); private volatile List<SoundPack> available = List.of();
private volatile boolean soundsMuted;
public SoundNotifier(Settings settings) { public SoundNotifier(Settings settings) {
this.settings = settings; this.settings = settings;
@@ -55,6 +56,19 @@ public final class SoundNotifier {
settings.soundPack = pack == null ? "" : pack.id(); settings.soundPack = pack == null ? "" : pack.id();
} }
/**
* Silences every notification sound until turned back on — TS3's "Mute Sounds"
* hotkey action. Unlike deafening, this is not tied to a server and even important
* actions stay quiet.
*/
public void setMuted(boolean muted) {
this.soundsMuted = muted;
}
public boolean isMuted() {
return soundsMuted;
}
/** /**
* Plays the sound for an action. * Plays the sound for an action.
* *
@@ -70,6 +84,7 @@ public final class SoundNotifier {
* {@code clientname} or {@code channelname} * {@code clientname} or {@code channelname}
*/ */
public void fire(SoundEvent event, boolean muted, Map<String, String> variables) { public void fire(SoundEvent event, boolean muted, Map<String, String> variables) {
if (soundsMuted) return;
NotificationSettings notifications = settings.notifications; NotificationSettings notifications = settings.notifications;
if (!notifications.isEnabled(event)) return; if (!notifications.isEnabled(event)) return;
if (muted && !notifications.isImportant(event)) return; if (muted && !notifications.isImportant(event)) return;
@@ -98,7 +113,7 @@ public final class SoundNotifier {
if (script == null) return; if (script == null) return;
String resolved = script.resolve(withDefaults(variables)); String resolved = script.resolve(withDefaults(variables));
double volume = Math.max(0, Math.min(1.0, settings.soundVolume)); double volume = Math.max(0, Math.min(1.0, settings.effectiveSoundVolume()));
if (volume <= 0) return; if (volume <= 0) return;
if (script.kind() == SoundScript.Kind.SAY) { if (script.kind() == SoundScript.Kind.SAY) {

View File

@@ -1,5 +1,7 @@
package com.ts3client.sound; package com.ts3client.sound;
import com.ts3client.config.InstallDirs;
import java.io.File; import java.io.File;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
@@ -15,10 +17,6 @@ import java.util.Map;
*/ */
public final class SoundPacks { public final class SoundPacks {
/** Overrides the TeamSpeak install location the packs are borrowed from. */
private static final String CLIENT_DIR_PROPERTY = "ts3.client.dir";
private static final String CLIENT_DIR_ENV = "TS3_CLIENT_DIR";
private SoundPacks() { private SoundPacks() {
} }
@@ -32,7 +30,7 @@ public final class SoundPacks {
*/ */
public static List<SoundPack> findAll(File userDirectory, String extraDirectory) { public static List<SoundPack> findAll(File userDirectory, String extraDirectory) {
Map<String, SoundPack> byId = new LinkedHashMap<>(); Map<String, SoundPack> byId = new LinkedHashMap<>();
for (File root : searchRoots(userDirectory, extraDirectory)) { for (File root : InstallDirs.searchRoots("sound", userDirectory, extraDirectory)) {
File[] entries = root.listFiles(File::isDirectory); File[] entries = root.listFiles(File::isDirectory);
if (entries == null) continue; if (entries == null) continue;
for (File dir : entries) { for (File dir : entries) {
@@ -52,38 +50,4 @@ public final class SoundPacks {
} }
return packs.isEmpty() ? null : packs.get(0); return packs.isEmpty() ? null : packs.get(0);
} }
private static List<File> searchRoots(File userDirectory, String extraDirectory) {
List<File> roots = new ArrayList<>();
if (userDirectory != null) roots.add(userDirectory);
if (extraDirectory != null && !extraDirectory.isBlank()) roots.add(new File(extraDirectory.trim()));
String configured = System.getProperty(CLIENT_DIR_PROPERTY, System.getenv(CLIENT_DIR_ENV));
if (configured != null && !configured.isBlank()) roots.add(new File(configured.trim(), "sound"));
String home = System.getProperty("user.home", ".");
for (String candidate : new String[]{
"TeamSpeak3-Client-linux_amd64", "TeamSpeak3-Client-linux_x86",
".local/share/TeamSpeak3-Client-linux_amd64", "Applications/TeamSpeak3-Client-linux_amd64"}) {
roots.add(new File(new File(home, candidate), "sound"));
}
for (String candidate : new String[]{
"/opt/teamspeak3-client", "/opt/teamspeak3", "/usr/share/teamspeak3",
"/usr/share/teamspeak3-client"}) {
roots.add(new File(candidate, "sound"));
}
String programFiles = System.getenv("ProgramFiles");
if (programFiles != null) {
roots.add(new File(programFiles, "TeamSpeak 3 Client" + File.separator + "sound"));
}
// Running from a checkout that has the official client unpacked next to it.
roots.add(new File("TeamSpeak3-Client-linux_amd64/sound"));
roots.add(new File("../TeamSpeak3-Client-linux_amd64/sound"));
List<File> existing = new ArrayList<>();
for (File root : roots) {
if (root.isDirectory()) existing.add(root);
}
return existing;
}
} }

View File

@@ -37,6 +37,10 @@ public final class BBCode {
/** CSS class on links that leave the client, so they are visibly not an identity. */ /** CSS class on links that leave the client, so they are visibly not an identity. */
public static final String EXTERNAL_LINK_CLASS = "extlink"; public static final String EXTERNAL_LINK_CLASS = "extlink";
/** A single {@code [url=href]label[/url]} pair, the only markup the log carries. */
private static final Pattern URL_LINK =
Pattern.compile("(?i)\\[url=([^\\]]+)](.*?)\\[/url]");
private BBCode() { private BBCode() {
} }
@@ -70,6 +74,29 @@ public final class BBCode {
return out.toString(); return out.toString();
} }
/**
* Renders text carrying {@code [url=…]…[/url]} links and nothing else: everything
* outside those tags stays literal. The server log is written this way, so that a
* name happening to contain bracket markup is shown as it is rather than rendered.
*/
public static String linksToHtml(String input) {
if (input == null || input.isEmpty()) return "";
StringBuilder out = new StringBuilder();
Matcher m = URL_LINK.matcher(input);
int last = 0;
while (m.find()) {
out.append(escape(input.substring(last, m.start())));
String href = m.group(1);
if (SAFE_URL.matcher(href).matches()) {
out.append(link(href)).append(escape(m.group(2))).append(linkClose(href));
} else {
out.append(escape(m.group()));
}
last = m.end();
}
return out.append(escape(input.substring(last))).toString();
}
/** Escapes plain text for HTML without interpreting any BBCode. */ /** Escapes plain text for HTML without interpreting any BBCode. */
public static String escape(String s) { public static String escape(String s) {
if (s == null) return ""; if (s == null) return "";

View File

@@ -0,0 +1,58 @@
package com.ts3client.audio.vad;
import org.junit.jupiter.api.Test;
import java.util.Random;
import static org.junit.jupiter.api.Assertions.assertEquals;
class RealFftTest {
/**
* The rnn_vad transform length is 480 = 2^5 * 3 * 5, so the mixed-radix path has to be
* right for factors other than powers of two.
*/
@Test
void matchesDirectDftForMixedRadixLengths() {
for (int n : new int[]{480, 240, 512, 64, 60, 210, 100}) {
RealFft fft = new RealFft(n);
Random random = new Random(n);
float[] in = new float[n];
float[] out = new float[n];
for (int i = 0; i < n; i++) {
in[i] = (float) random.nextGaussian();
}
fft.forward(in, out);
for (int k = 0; k <= n / 2; k++) {
double re = 0;
double im = 0;
for (int t = 0; t < n; t++) {
double angle = -2 * Math.PI * k * t / n;
re += in[t] * Math.cos(angle);
im += in[t] * Math.sin(angle);
}
double gotRe;
double gotIm;
if (k == 0) {
gotRe = out[0];
gotIm = 0;
} else if (k == n / 2) {
gotRe = out[1]; // Packed layout keeps Nyquist in slot 1.
gotIm = 0;
} else {
gotRe = out[2 * k];
gotIm = out[2 * k + 1];
}
assertEquals(re, gotRe, 1e-2, "n=" + n + " bin=" + k + " real");
assertEquals(im, gotIm, 1e-2, "n=" + n + " bin=" + k + " imag");
}
}
}
@Test
void rejectsOddLengths() {
org.junit.jupiter.api.Assertions.assertThrows(
IllegalArgumentException.class, () -> new RealFft(481));
}
}

View File

@@ -0,0 +1,129 @@
package com.ts3client.audio.vad;
import org.junit.jupiter.api.Test;
import java.util.Random;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Behavioural checks on the {@code rnn_vad} port. The network weights are the ones the TS3
* client links, so these also pin the detector's agreement with it.
*/
class RnnVadTest {
private static final int FRAMES = 60;
@Test
void silenceScoresZero() {
float[] x = new float[RnnVad.FRAME_SIZE * FRAMES];
double[] probabilities = run(x);
for (double p : probabilities) {
assertEquals(0.0, p, 0.0, "silence must not score above zero");
}
}
@Test
void voicedSpeechLikeSignalScoresHigh() {
float[] x = synth(FRAMES, t -> {
double sum = 0;
for (int h = 1; h <= 30 && 120 * h < 8000; h++) {
double f = 120 * h;
double gain = formant(f, 700, 90) + formant(f, 1220, 110) + formant(f, 2600, 160);
sum += gain * Math.sin(2 * Math.PI * f * t + h);
}
// A slow tremolo keeps it from looking like a stationary tone.
return 0.25 * (0.6 + 0.4 * Math.sin(2 * Math.PI * 5 * t)) * sum / 6.0;
});
// Skip the first frames: the pitch buffer and GRU need history.
assertTrue(meanFrom(run(x), 20) > 0.8, "a voiced signal should score high");
}
@Test
void quietPinkNoiseScoresLow() {
Random random = new Random(3);
float[] x = new float[RnnVad.FRAME_SIZE * FRAMES];
double b0 = 0;
double b1 = 0;
double b2 = 0;
for (int i = 0; i < x.length; i++) {
double w = random.nextGaussian();
b0 = 0.99765 * b0 + w * 0.0990460;
b1 = 0.96300 * b1 + w * 0.2965164;
b2 = 0.57000 * b2 + w * 1.0526913;
// About -45 dBFS: representative of room tone rather than loud hiss.
x[i] = (float) ((b0 + b1 + b2 + w * 0.1848) * 0.0011);
}
assertTrue(meanFrom(run(x), 20) < 0.2, "quiet room tone should not read as speech");
}
@Test
void estimatesPitchPeriodExactly() {
// The pitch feature is fed to the network as 0.01 * (period48k - 300).
for (double f0 : new double[]{80, 100, 120, 150, 200, 250, 300, 400}) {
RnnVad vad = new RnnVad();
float[] x = synth(FRAMES, t -> {
double sum = 0;
for (int h = 1; h <= 40 && f0 * h < 11000; h++) {
sum += Math.sin(2 * Math.PI * f0 * h * t) / h;
}
return 0.2 * sum;
});
for (int f = 0; f < FRAMES; f++) {
vad.process(x, f * RnnVad.FRAME_SIZE);
}
assertEquals(48000 / f0, vad.pitchPeriod48kHz(), 1e-9,
"pitch period for f0=" + f0);
}
}
@Test
void resetClearsState() {
float[] x = synth(FRAMES, t -> 0.3 * Math.sin(2 * Math.PI * 200 * t));
RnnVad vad = new RnnVad();
for (int f = 0; f < FRAMES; f++) {
vad.process(x, f * RnnVad.FRAME_SIZE);
}
assertTrue(vad.probability() > 0.0);
vad.reset();
assertEquals(0.0, vad.probability(), 0.0);
}
// ---- helpers ----
private interface Signal {
double at(double t);
}
private static double formant(double f, double centre, double bandwidth) {
double d = (f - centre) / bandwidth;
return 1.0 / (1.0 + d * d);
}
private static float[] synth(int frames, Signal signal) {
float[] x = new float[RnnVad.FRAME_SIZE * frames];
for (int i = 0; i < x.length; i++) {
x[i] = (float) signal.at((double) i / RnnVad.SAMPLE_RATE);
}
return x;
}
private static double[] run(float[] x) {
RnnVad vad = new RnnVad();
int frames = x.length / RnnVad.FRAME_SIZE;
double[] probabilities = new double[frames];
for (int i = 0; i < frames; i++) {
probabilities[i] = vad.process(x, i * RnnVad.FRAME_SIZE);
}
return probabilities;
}
private static double meanFrom(double[] values, int start) {
double sum = 0;
for (int i = start; i < values.length; i++) {
sum += values[i];
}
return sum / (values.length - start);
}
}

View File

@@ -0,0 +1,127 @@
package com.ts3client.gfx;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class IconPackTest {
private static final String INI = """
[info]
name = Test Icons
[options]
FALLBACK = false
[gfxfiles]
# a comment
CONNECT = connect.svg
DISCONNECT = Disconnect.SVG
CHANNEL_GREEN= channel_green.png
MISSING = not_shipped.svg
EMPTY =
""";
private static File zip(Path dir, String name, Map<String, String> entries) throws Exception {
File file = dir.resolve(name).toFile();
try (OutputStream out = Files.newOutputStream(file.toPath());
ZipOutputStream zip = new ZipOutputStream(out)) {
for (Map.Entry<String, String> e : entries.entrySet()) {
zip.putNextEntry(new ZipEntry(e.getKey()));
zip.write(e.getValue().getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
}
return file;
}
@Test
void readsTheMappingOfAModernPack(@TempDir Path dir) throws Exception {
IconPack pack = IconPack.load(zip(dir, "test_pack.zip", Map.of(
"settings.ini", INI,
"connect.svg", "<svg/>",
"disconnect.svg", "<svg>disconnect</svg>",
"channel_green.png", "png bytes",
"emoticons/smile.svg", "<svg/>")));
assertNotNull(pack);
assertEquals("test_pack.zip", pack.id());
assertEquals("Test Icons", pack.name());
assertFalse(pack.fallsBack());
assertArrayEquals("<svg/>".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16));
assertTrue(pack.isVector("CONNECT", 16));
assertFalse(pack.isVector("CHANNEL_GREEN", 16));
// Keys are case-insensitive, as are the file names pack authors write.
assertArrayEquals("<svg>disconnect</svg>".getBytes(StandardCharsets.UTF_8),
pack.icon("disconnect", 16));
assertNull(pack.icon("MISSING", 16), "mapped to a file the pack does not ship");
assertNull(pack.icon("EMPTY", 16));
assertNull(pack.icon("NOT_IN_THE_PACK", 16));
}
@Test
void fallsBackByDefault(@TempDir Path dir) throws Exception {
IconPack pack = IconPack.load(zip(dir, "p.zip", Map.of(
"settings.ini", "[gfxfiles]\nCONNECT = connect.svg\n",
"connect.svg", "<svg/>")));
assertNotNull(pack);
assertTrue(pack.fallsBack());
}
@Test
void resolvesLegacyPacksByFileName(@TempDir Path dir) throws Exception {
IconPack pack = IconPack.load(zip(dir, "default.zip", Map.of(
"16x16_connect.png", "small",
"32x32_connect.png", "large",
"24x24_upload_avatar.png", "avatar",
"group_100.png", "group",
"emoticons/smile.png", "ignored")));
assertNotNull(pack);
assertEquals("Default", pack.name());
// The smallest image that still covers the requested size wins.
assertArrayEquals("small".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16));
assertArrayEquals("large".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 32));
assertArrayEquals("large".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 64));
assertArrayEquals("avatar".getBytes(StandardCharsets.UTF_8), pack.icon("UPLOAD_AVATAR", 16));
assertArrayEquals("group".getBytes(StandardCharsets.UTF_8), pack.icon("GROUP_100", 16));
assertFalse(pack.has("SMILE"), "emoticons are not icons of the user interface");
}
@Test
void readsUnpackedDirectories(@TempDir Path dir) throws Exception {
Path unpacked = Files.createDirectory(dir.resolve("my_icons"));
Files.writeString(unpacked.resolve("settings.ini"), "[gfxfiles]\nCONNECT = connect.svg\n");
Files.writeString(unpacked.resolve("connect.svg"), "<svg/>");
IconPack pack = IconPack.load(unpacked.toFile());
assertNotNull(pack);
assertEquals("My Icons", pack.name());
assertArrayEquals("<svg/>".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16));
}
@Test
void ignoresWhatIsNotAPack(@TempDir Path dir) throws Exception {
assertNull(IconPack.load(dir.resolve("nope.zip").toFile()));
assertNull(IconPack.load(Files.createDirectory(dir.resolve("empty")).toFile()));
assertNull(IconPack.load(null));
}
}

View File

@@ -0,0 +1,120 @@
package com.ts3client.hotkey;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HotkeyEngineTest {
private static final HotkeyKey CTRL = HotkeyKey.keyboard(37);
private static final HotkeyKey K = HotkeyKey.keyboard(45);
private static final HotkeyKey MOUSE4 = HotkeyKey.mouse(8);
private final List<String> fired = new ArrayList<>();
private final Hotkeys hotkeys = new Hotkeys();
private final HotkeyEngine engine = new HotkeyEngine(hotkeys,
(hotkey, active) -> fired.add(hotkey.action + (active ? ":on" : ":off")));
private Hotkey bind(HotkeyAction action, Hotkey.Trigger trigger, HotkeyKey... keys) {
Hotkey h = new Hotkey(action, new HotkeyCombo(List.of(keys)));
h.trigger = trigger;
hotkeys.add(h);
return h;
}
@Test
void firesOnKeyDownOnlyWhenEveryKeyIsHeld() {
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, CTRL, K);
engine.onInput(K, true);
assertTrue(fired.isEmpty(), "one key of the combination is not enough");
engine.onInput(CTRL, true);
assertEquals(List.of("MIC_TOGGLE:on"), fired);
// Releasing and re-pressing arms it again, but only once per completion.
engine.onInput(CTRL, false);
engine.onInput(CTRL, true);
assertEquals(List.of("MIC_TOGGLE:on", "MIC_TOGGLE:on"), fired);
}
@Test
void keyUpTriggerFiresOnRelease() {
bind(HotkeyAction.SPEAKER_TOGGLE, Hotkey.Trigger.KEY_UP, MOUSE4);
engine.onInput(MOUSE4, true);
assertTrue(fired.isEmpty());
engine.onInput(MOUSE4, false);
assertEquals(List.of("SPEAKER_TOGGLE:on"), fired);
}
@Test
void momentaryActionReportsPressAndRelease() {
bind(HotkeyAction.PTT_ACTIVATE, Hotkey.Trigger.KEY_UP, MOUSE4);
engine.onInput(MOUSE4, true);
engine.onInput(MOUSE4, false);
// The trigger setting does not apply: push-to-talk always spans the hold.
assertEquals(List.of("PTT_ACTIVATE:on", "PTT_ACTIVATE:off"), fired);
}
@Test
void disabledAndUnsupportedBindingsStaySilent() {
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, K).enabled = false;
bind(HotkeyAction.RECORDING_START, Hotkey.Trigger.KEY_DOWN, MOUSE4);
engine.onInput(K, true);
engine.onInput(MOUSE4, true);
assertTrue(fired.isEmpty());
}
@Test
void recordingCapturesTheCombinationInsteadOfFiring() {
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, K);
List<HotkeyCombo> recorded = new ArrayList<>();
engine.record(new HotkeyEngine.Recorder() {
@Override
public void onRecording(HotkeyCombo combo) {
}
@Override
public void onRecorded(HotkeyCombo combo) {
recorded.add(combo);
}
});
engine.onInput(CTRL, true);
engine.onInput(K, true);
engine.onInput(K, false);
engine.onInput(CTRL, false);
assertTrue(fired.isEmpty(), "bindings must not fire while recording");
assertEquals(1, recorded.size());
assertEquals(List.of(CTRL, K), recorded.get(0).keys());
}
@Test
void actionsCarryTeamSpeaksTreeGrouping() {
assertEquals("Sounds", HotkeyAction.SOUNDPACK_ACTIVATE.group());
assertEquals("Microphone", HotkeyAction.MIC_TOGGLE.group());
// TS3 leaves a handful of actions ungrouped, listed straight under their category.
assertEquals("", HotkeyAction.NICKNAME_CHANGE.group());
assertEquals("", HotkeyAction.BRING_TO_FRONT.group());
Hotkey pack = new Hotkey(HotkeyAction.SOUNDPACK_ACTIVATE, new HotkeyCombo(List.of(K)));
pack.argument = "Default Sound Pack (Male)";
assertEquals("Sounds / Activate Soundpack / Default Sound Pack (Male)", pack.path());
assertEquals("Bring Client to Front",
new Hotkey(HotkeyAction.BRING_TO_FRONT, new HotkeyCombo(List.of(K))).path());
}
@Test
void combinationsSurviveARoundTripThroughText() {
HotkeyCombo combo = new HotkeyCombo(List.of(CTRL, MOUSE4));
assertEquals("Keyboard:37+Mouse:8", combo.toString());
assertEquals(combo, HotkeyCombo.parse(combo.toString()));
}
}

View File

@@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
/** /**
* Pins the ts3j behaviour {@code TeamspeakConnection.has(...)} exists for: a field * Pins the ts3j behaviour {@code ConnectionEventHandler.has(...)} exists for: a field
* the event never carried reads back as an empty string, so a null check would treat * the event never carried reads back as an empty string, so a null check would treat
* every partial update (someone muting, say) as if it reported every other field too. * every partial update (someone muting, say) as if it reported every other field too.
*/ */

View File

@@ -0,0 +1,40 @@
package com.ts3client.text;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The server log's renderer: it turns the client's own links into anchors and leaves
* everything else — names carrying bracket markup included — as plain text.
*/
class BBCodeLinksTest {
@Test
void rendersClientAndChannelLinks() {
String log = TsLink.clientBBCode(7, "uid=", "Bob") + " connected to channel \""
+ TsLink.channelBBCode(3, "Lobby") + "\"";
String html = BBCode.linksToHtml(log);
assertTrue(html.contains("href=\"client://7/uid=~Bob\""), html);
assertTrue(html.contains(">Bob</a>"), html);
assertTrue(html.contains("href=\"channel://3/Lobby\""), html);
assertTrue(html.contains(">Lobby</a>"), html);
}
@Test
void leavesOtherMarkupLiteral() {
assertEquals("[b]not bold[/b] &amp; &lt;i&gt;", BBCode.linksToHtml("[b]not bold[/b] & <i>"));
}
@Test
void keepsBracketsInsideALabelLiteral() {
String html = BBCode.linksToHtml(TsLink.channelBBCode(3, "[cspacer]---"));
assertTrue(html.contains(">[cspacer]---</a>"), html);
}
@Test
void rejectsHrefsThatAreNotLinks() {
assertEquals("[url=javascript:alert(1)]x[/url]", BBCode.linksToHtml("[url=javascript:alert(1)]x[/url]"));
}
}

View File

@@ -2,9 +2,12 @@ package com.ts3client.audio.desktop;
import com.github.manevolent.ts3j.enums.CodecType; import com.github.manevolent.ts3j.enums.CodecType;
import com.ts3client.audio.AudioEnhancer; import com.ts3client.audio.AudioEnhancer;
import com.ts3client.audio.AudioFrameListener;
import com.ts3client.audio.InputLevel;
import com.ts3client.audio.OpusParameters; import com.ts3client.audio.OpusParameters;
import com.ts3client.audio.SpeechDetector; import com.ts3client.audio.SpeechProbabilityDetector;
import com.ts3client.audio.VoiceInput; import com.ts3client.audio.VoiceInput;
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;
@@ -28,10 +31,27 @@ import java.util.function.Consumer;
*/ */
public final class DesktopVoiceInput implements VoiceInput { public final class DesktopVoiceInput 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 * AudioDevices.SAMPLE_RATE / 1000 / AudioDevices.FRAME_SIZE);
private static final int PREROLL_FRAMES =
Math.max(1, PREROLL_MS * AudioDevices.SAMPLE_RATE / 1000 / AudioDevices.FRAME_SIZE);
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,12 +64,14 @@ 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 =
new RnnSpeechDetector(AudioDevices.SAMPLE_RATE);
private final AudioEnhancer enhancer = new AudioEnhancer(AudioDevices.SAMPLE_RATE); private final AudioEnhancer enhancer = new AudioEnhancer(AudioDevices.SAMPLE_RATE);
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 String deviceName;
@@ -68,6 +90,11 @@ public final class DesktopVoiceInput implements VoiceInput {
private int hangover; private int hangover;
private boolean lastTransmitting; private boolean lastTransmitting;
// 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 DesktopVoiceInput(Settings settings) { public DesktopVoiceInput(Settings settings) {
this.deviceName = settings.inputDevice; this.deviceName = settings.inputDevice;
this.mode = settings.inputMode; this.mode = settings.inputMode;
@@ -195,6 +222,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,6 +240,16 @@ 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() {
@@ -305,36 +347,76 @@ public final class DesktopVoiceInput implements VoiceInput {
// stream bypasses them and is transmitted as captured. // stream bypasses them and is transmitted as captured.
if (!stereo) enhancer.process(mono, frameSamples); if (!stereo) enhancer.process(mono, frameSamples);
double sumSq = 0; double db = InputLevel.toDb(mono, frameSamples);
for (int i = 0; i < frameSamples; i++) {
sumSq += (double) mono[i] * mono[i];
}
double rms = Math.sqrt(sumSq / 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 wasOpen = transmitting.get();
boolean open = decideGate(db, mono); boolean open = decideGate(db, mono);
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 void offer(byte[] packet) {
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 ? AudioDevices.FRAME_SIZE * encoderChannels : AudioDevices.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;
}
private boolean decideGate(double db, float[] pcm) { private boolean decideGate(double db, float[] pcm) {
if (muted.get()) { if (muted.get() || localMuted.get()) {
hangover = 0; hangover = 0;
detectMutedSpeech(db); detectMutedSpeech(db);
return false; return false;
@@ -353,8 +435,10 @@ public final class DesktopVoiceInput implements VoiceInput {
} }
/** /**
* 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, float[] pcm) {
boolean detected; boolean detected;
@@ -363,11 +447,11 @@ public final class DesktopVoiceInput implements VoiceInput {
detected = db >= thresholdDb; detected = db >= thresholdDb;
break; break;
case AUTOMATIC: case AUTOMATIC:
detected = speechDetector.process(pcm) >= speechThreshold; detected = speechDetector.process(pcm, AudioDevices.FRAME_SIZE) >= speechThreshold;
break; break;
case HYBRID: case HYBRID:
default: default:
double probability = speechDetector.process(pcm); double probability = speechDetector.process(pcm, AudioDevices.FRAME_SIZE);
detected = db >= thresholdDb && probability >= speechThreshold; detected = db >= thresholdDb && probability >= speechThreshold;
break; break;
} }
@@ -395,6 +479,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

@@ -10,6 +10,8 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
/** /**
@@ -24,6 +26,16 @@ public final class DesktopVoiceOutput implements VoiceOutput {
/** Longest Opus frame (120 ms @ 48 kHz) a packet may decode to, per channel. */ /** Longest Opus frame (120 ms @ 48 kHz) a packet may decode to, per channel. */
private static final int MAX_FRAME = 5760; private static final int MAX_FRAME = 5760;
/**
* 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(200);
/** One speaker's decode + playback pipeline. */ /** One speaker's decode + playback pipeline. */
private final class ClientStream { private final class ClientStream {
final int clientId; final int clientId;
@@ -81,8 +93,32 @@ public final class DesktopVoiceOutput implements VoiceOutput {
/** Notified (clientId, talking) on the EDT-agnostic worker thread when a speaker starts/stops. */ /** Notified (clientId, talking) on the EDT-agnostic worker thread when a speaker starts/stops. */
private volatile BiConsumer<Integer, Boolean> talkListener; private volatile BiConsumer<Integer, Boolean> talkListener;
/** Catches a talk burst whose end packet never arrived; see {@link #TALK_TIMEOUT_NANOS}. */
private final ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "ts3j-talk-watchdog");
t.setDaemon(true);
return t;
});
public DesktopVoiceOutput(String outputDevice) { public DesktopVoiceOutput(String outputDevice) {
this.outputDevice = outputDevice; this.outputDevice = outputDevice;
watchdog.scheduleWithFixedDelay(this::checkTalkTimeouts, 50, 50, TimeUnit.MILLISECONDS);
}
private void checkTalkTimeouts() {
long now = System.nanoTime();
for (ClientStream s : streams.values()) {
if (s.talking && now - s.lastPacketNanos > TALK_TIMEOUT_NANOS) {
s.worker.submit(() -> {
try {
s.line.drain();
} catch (Exception ignored) {
}
if (s.decoder != null) s.decoder.reset();
markTalking(s, false);
});
}
}
} }
public void setTalkListener(BiConsumer<Integer, Boolean> l) { public void setTalkListener(BiConsumer<Integer, Boolean> l) {
@@ -226,6 +262,7 @@ public final class DesktopVoiceOutput implements VoiceOutput {
} }
public void shutdown() { public void shutdown() {
watchdog.shutdownNow();
for (ClientStream s : streams.values()) { for (ClientStream s : streams.values()) {
s.close(); s.close();
} }

View File

@@ -0,0 +1,102 @@
package com.ts3client.hotkey.desktop;
import com.ts3client.hotkey.GlobalInputHook;
import com.ts3client.hotkey.HotkeyKey;
import java.util.ArrayList;
import java.util.List;
/**
* Picks the global input hook that suits the running session and starts it.
*
* <p>Windows has one answer, raw input. On X11 it is XInput2, with RECORD behind it for
* servers that lack it: neither needs privileges or devices to open. On Wayland the X
* server sees only what the compositor forwards, so reading evdev is the surest way to
* catch every key — and when that is not permitted either, the returned hook is simply
* not running and says why.
*/
public final class DesktopInputHooks {
private DesktopInputHooks() {
}
/** @return a hook, started when it could be; check {@link GlobalInputHook#isRunning()} */
public static GlobalInputHook start(GlobalInputHook.Listener listener) {
List<String> reasons = new ArrayList<>();
for (GlobalInputHook hook : candidates()) {
hook.start(listener);
if (hook.isRunning()) return hook;
reasons.add(hook.getClass().getSimpleName() + ": " + hook.unavailableReason());
hook.close();
}
return new Unavailable(reasons.isEmpty()
? "no global input backend for this session"
: String.join("; ", reasons));
}
private static List<GlobalInputHook> candidates() {
List<GlobalInputHook> hooks = new ArrayList<>();
if (Win32.isWindows()) {
if (WindowsInputHook.isSupported()) hooks.add(new WindowsInputHook());
return hooks;
}
if (isWayland()) {
// Evdev first: it is the only backend guaranteed to see keys aimed at native
// Wayland windows. Failing that, Xwayland still covers every X11 app, and on
// compositors that forward the rest — KWin, with legacy X11 app support on —
// it covers those too.
if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook());
addX11Hooks(hooks);
} else {
addX11Hooks(hooks);
if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook());
}
return hooks;
}
/**
* RECORD before XInput2: on at least some driver stacks, XInput2's raw
* {@code XI_RawButtonRelease} never reaches a client that hasn't grabbed the pointer —
* see {@link XInput2InputHook} — which makes a bound mouse button look stuck down.
* RECORD taps the same core events {@code xev} sees and is not affected. XInput2 stays
* as the fallback for servers where RECORD is disabled or missing.
*/
private static void addX11Hooks(List<GlobalInputHook> hooks) {
if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook());
if (XInput2InputHook.isSupported()) hooks.add(new XInput2InputHook());
}
private static boolean isWayland() {
return System.getenv("WAYLAND_DISPLAY") != null
|| "wayland".equalsIgnoreCase(System.getenv("XDG_SESSION_TYPE"));
}
/** Stands in when no backend could start, so callers need no null checks. */
private record Unavailable(String reason) implements GlobalInputHook {
@Override
public void start(Listener listener) {
}
@Override
public boolean isRunning() {
return false;
}
@Override
public String unavailableReason() {
return reason;
}
@Override
public String keyName(HotkeyKey key) {
// Bindings recorded earlier still deserve their real names in the dialog,
// even with no hook running to have produced them.
return Win32.isWindows() ? WindowsKeyNamer.name(key) : X11KeyNamer.name(key);
}
@Override
public void close() {
}
}
}

View File

@@ -0,0 +1,151 @@
package com.ts3client.hotkey.desktop;
import com.ts3client.hotkey.GlobalInputHook;
import com.ts3client.hotkey.HotkeyKey;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.List;
/**
* System-wide input hook that reads the kernel's evdev devices directly, for sessions
* where {@link XRecordInputHook} cannot see everything — Wayland above all, where the X
* server is only told about events aimed at X clients.
*
* <p>It needs read access to {@code /dev/input/event*}, which normally means membership
* of the {@code input} group; without it the hook reports itself unavailable and the
* client carries on without global hotkeys.
*
* <p>Key codes are reported as X keycodes (the kernel code plus 8) and buttons as X
* button numbers, so bindings mean the same thing whichever backend recorded them.
*/
public final class EvdevInputHook implements GlobalInputHook {
private static final File INPUT_DIR = new File("/dev/input");
/** {@code struct input_event} on 64-bit Linux: two 8-byte time fields, then type/code/value. */
private static final int EVENT_SIZE = 24;
private static final int EV_KEY = 1;
/** Kernel key codes below this are keyboard keys; from here up they are BTN_* buttons. */
private static final int BTN_MISC = 0x100;
private static final int BTN_LEFT = 0x110;
private final List<InputStream> streams = new ArrayList<>();
private final List<Thread> threads = new ArrayList<>();
private volatile Listener listener;
private volatile boolean running;
private volatile String unavailable = "not started";
public static boolean isSupported() {
File[] devices = INPUT_DIR.listFiles((dir, name) -> name.startsWith("event"));
if (devices == null) return false;
for (File device : devices) {
if (device.canRead()) return true;
}
return false;
}
@Override
public void start(Listener listener) {
this.listener = listener;
File[] devices = INPUT_DIR.listFiles((dir, name) -> name.startsWith("event"));
if (devices == null || devices.length == 0) {
unavailable = "no /dev/input devices";
return;
}
for (File device : devices) {
try {
InputStream in = new FileInputStream(device);
streams.add(in);
Thread t = new Thread(() -> read(in), "ts3j-hotkeys-evdev-" + device.getName());
t.setDaemon(true);
threads.add(t);
} catch (Exception ignored) {
// Devices we may not read are simply skipped.
}
}
if (streams.isEmpty()) {
unavailable = "no readable /dev/input device — add your user to the \"input\" group";
return;
}
running = true;
unavailable = "";
for (Thread t : threads) t.start();
}
private void read(InputStream in) {
byte[] buffer = new byte[EVENT_SIZE * 16];
ByteBuffer view = ByteBuffer.wrap(buffer).order(ByteOrder.nativeOrder());
while (running) {
try {
int read = in.read(buffer);
if (read < 0) return;
for (int offset = 0; offset + EVENT_SIZE <= read; offset += EVENT_SIZE) {
int type = view.getShort(offset + 16) & 0xffff;
int code = view.getShort(offset + 18) & 0xffff;
int value = view.getInt(offset + 20);
// 2 is auto-repeat, which must not look like a fresh press.
if (type == EV_KEY && value != 2) dispatch(code, value == 1);
}
} catch (Exception e) {
return;
}
}
}
private void dispatch(int code, boolean pressed) {
Listener l = listener;
if (l == null) return;
HotkeyKey key = code < BTN_MISC ? HotkeyKey.keyboard(code + 8) : mouse(code);
if (key != null) l.onInput(key, pressed);
}
/** Maps the kernel's BTN_* codes onto the X button numbering. */
private static HotkeyKey mouse(int code) {
return switch (code) {
case BTN_LEFT -> HotkeyKey.mouse(1);
case BTN_LEFT + 1 -> HotkeyKey.mouse(3); // BTN_RIGHT
case BTN_LEFT + 2 -> HotkeyKey.mouse(2); // BTN_MIDDLE
case BTN_LEFT + 3 -> HotkeyKey.mouse(8); // BTN_SIDE, "mouse 4"
case BTN_LEFT + 4 -> HotkeyKey.mouse(9); // BTN_EXTRA, "mouse 5"
case BTN_LEFT + 5 -> HotkeyKey.mouse(10); // BTN_FORWARD
case BTN_LEFT + 6 -> HotkeyKey.mouse(11); // BTN_BACK
case BTN_LEFT + 7 -> HotkeyKey.mouse(12); // BTN_TASK
default -> null; // joysticks, lid switches, …
};
}
@Override
public boolean isRunning() {
return running;
}
@Override
public String unavailableReason() {
return running ? "" : unavailable;
}
@Override
public String keyName(HotkeyKey key) {
// The kernel knows scancodes, not layouts; let X name the key when it can.
return X11KeyNamer.name(key);
}
@Override
public void close() {
running = false;
listener = null;
// Closing the descriptor is what breaks the readers out of their blocking read.
for (InputStream in : streams) {
try {
in.close();
} catch (Exception ignored) {
}
}
streams.clear();
threads.clear();
}
}

View File

@@ -0,0 +1,47 @@
package com.ts3client.hotkey.desktop;
import java.util.Map;
/** Turns X keysym names into the labels users expect on a hotkey button. */
final class KeyNames {
private KeyNames() {
}
private static final Map<String, String> SPECIAL = Map.ofEntries(
Map.entry("Control_L", "Left Ctrl"),
Map.entry("Control_R", "Right Ctrl"),
Map.entry("Shift_L", "Left Shift"),
Map.entry("Shift_R", "Right Shift"),
Map.entry("Alt_L", "Left Alt"),
Map.entry("Alt_R", "Right Alt"),
Map.entry("ISO_Level3_Shift", "AltGr"),
Map.entry("Super_L", "Left Super"),
Map.entry("Super_R", "Right Super"),
Map.entry("Meta_L", "Left Meta"),
Map.entry("Meta_R", "Right Meta"),
Map.entry("Prior", "Page Up"),
Map.entry("Next", "Page Down"),
Map.entry("Return", "Enter"),
Map.entry("space", "Space"),
Map.entry("BackSpace", "Backspace"),
Map.entry("Escape", "Esc"),
Map.entry("Caps_Lock", "Caps Lock"),
Map.entry("Num_Lock", "Num Lock"),
Map.entry("Scroll_Lock", "Scroll Lock"),
Map.entry("Menu", "Menu"),
Map.entry("Print", "Print Screen"));
/**
* @param keysym the X name of the keysym, e.g. {@code a}, {@code Control_L}, {@code KP_Add}
* @return a display name, or {@code null} when there is nothing sensible to show
*/
static String pretty(String keysym) {
if (keysym == null || keysym.isBlank()) return null;
String special = SPECIAL.get(keysym);
if (special != null) return special;
if (keysym.startsWith("KP_")) return "Numpad " + pretty(keysym.substring(3));
if (keysym.length() == 1) return keysym.toUpperCase();
return keysym.replace('_', ' ');
}
}

View File

@@ -0,0 +1,109 @@
package com.ts3client.hotkey.desktop;
import com.ts3client.hotkey.HotkeyKey;
import java.util.ArrayList;
import java.util.List;
/**
* Layout of a {@code RAWINPUT} structure and the reading of the two kinds we ask for,
* split out from {@link WindowsInputHook} so the decoding can be tested anywhere.
*
* <p>Keyboards report a set-1 scan code, which is what a binding stores: it names the
* physical key, so it survives a layout change exactly as an X keycode does on Linux.
* Mice are renumbered to the X button numbering the rest of the client already speaks,
* so {@link HotkeyKey#fallbackName()} and the saved bindings mean the same thing on
* both platforms.
*/
final class RawInput {
private RawInput() {
}
/** {@code RAWINPUTHEADER.dwType}. */
static final int TYPE_MOUSE = 0;
static final int TYPE_KEYBOARD = 1;
/** Field offsets in {@code RAWINPUT} under x64, past the 24-byte header. */
static final long HEADER_TYPE = 0;
static final long HEADER_SIZE = 24;
static final long KEYBOARD_MAKE_CODE = 24;
static final long KEYBOARD_FLAGS = 26;
static final long KEYBOARD_VKEY = 30;
static final long MOUSE_BUTTON_FLAGS = 28;
/** Both event kinds fit comfortably; the larger, {@code RAWMOUSE}, needs 48 bytes. */
static final long BUFFER_SIZE = 64;
/** {@code RAWKEYBOARD.Flags}. */
static final int RI_KEY_BREAK = 0x01;
static final int RI_KEY_E0 = 0x02;
static final int RI_KEY_E1 = 0x04;
/** {@code VK_NO_VKEY}: the filler half of an escaped sequence, carrying no key. */
private static final int VKEY_NONE = 0xFF;
/** {@code RAWMOUSE.usButtonFlags}, in down/up pairs. */
private static final int[] BUTTON_FLAGS = {
0x0001, 0x0002, 1, // left
0x0010, 0x0020, 2, // middle
0x0004, 0x0008, 3, // right
0x0040, 0x0080, 8, // X1, "Mouse 4"
0x0100, 0x0200, 9, // X2, "Mouse 5"
};
private static final int RI_MOUSE_WHEEL = 0x0400;
private static final int RI_MOUSE_HWHEEL = 0x0800;
/**
* The scan code identifying a key, with the escape prefix folded into the high byte
* so that e.g. right Ctrl ({@code E0 1D}) stays distinct from left ({@code 1D}).
*
* @return the code, or -1 when the event names no key of its own
*/
static int scanCode(int makeCode, int flags, int vkey) {
if (vkey == VKEY_NONE) return -1;
int code = makeCode & 0xFF;
if (code == 0) return -1;
if ((flags & RI_KEY_E0) != 0) code |= 0xE000;
else if ((flags & RI_KEY_E1) != 0) code |= 0xE100;
return code;
}
static boolean isRelease(int flags) {
return (flags & RI_KEY_BREAK) != 0;
}
/** One button transition, in the order the flags word packs them. */
record ButtonEvent(HotkeyKey key, boolean pressed) {
}
/**
* Unpacks a mouse event, which may carry several transitions at once.
*
* @param buttonFlags {@code usButtonFlags}
* @param wheelDelta {@code usButtonData}, read as a signed delta when a wheel bit is set
*/
static List<ButtonEvent> buttons(int buttonFlags, short wheelDelta) {
List<ButtonEvent> events = new ArrayList<>();
for (int i = 0; i < BUTTON_FLAGS.length; i += 3) {
HotkeyKey key = HotkeyKey.mouse(BUTTON_FLAGS[i + 2]);
if ((buttonFlags & BUTTON_FLAGS[i]) != 0) events.add(new ButtonEvent(key, true));
if ((buttonFlags & BUTTON_FLAGS[i + 1]) != 0) events.add(new ButtonEvent(key, false));
}
// A wheel notch has no release of its own; X reports it as a button tap, and the
// hotkey engine expects the same shape, so synthesise both edges.
if ((buttonFlags & RI_MOUSE_WHEEL) != 0 && wheelDelta != 0) {
tap(events, wheelDelta > 0 ? 4 : 5);
}
if ((buttonFlags & RI_MOUSE_HWHEEL) != 0 && wheelDelta != 0) {
tap(events, wheelDelta > 0 ? 7 : 6);
}
return events;
}
private static void tap(List<ButtonEvent> events, int button) {
events.add(new ButtonEvent(HotkeyKey.mouse(button), true));
events.add(new ButtonEvent(HotkeyKey.mouse(button), false));
}
}

View File

@@ -0,0 +1,210 @@
package com.ts3client.hotkey.desktop;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
import java.nio.charset.StandardCharsets;
/**
* Raw binding to {@code user32} and {@code kernel32}, with just the calls
* {@link WindowsInputHook} needs to own a message-only window and read raw input from it.
*
* <p>Raw input is the polite way to watch the whole machine on Windows: registering with
* {@code RIDEV_INPUTSINK} delivers every key and button even while another application is
* in the foreground, and — unlike a {@code WH_KEYBOARD_LL} hook — it observes rather than
* intercepts, so nothing can be swallowed and nothing serialises the system input queue.
*
* <p>Loading is lazy and failure is expected off Windows, where {@link #isAvailable()}
* returns {@code false} and the caller falls back to another backend.
*/
final class Win32 {
private Win32() {
}
private static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT;
private static final ValueLayout.OfLong LONG = ValueLayout.JAVA_LONG;
private static final java.lang.foreign.AddressLayout PTR = ValueLayout.ADDRESS;
/** Window messages we care about. */
static final int WM_DESTROY = 0x0002;
static final int WM_CLOSE = 0x0010;
static final int WM_INPUT = 0x00FF;
/** {@code HWND_MESSAGE}: parent for a window that only ever receives messages. */
static final long HWND_MESSAGE = -3L;
/** {@code RIDEV_INPUTSINK}: deliver input even when the window is not in front. */
static final int RIDEV_INPUTSINK = 0x00000100;
/** HID usages for the two devices we register (usage page 1, "generic desktop"). */
static final int USAGE_PAGE_GENERIC = 0x01;
static final int USAGE_MOUSE = 0x02;
static final int USAGE_KEYBOARD = 0x06;
/** {@code RID_INPUT}: ask {@code GetRawInputData} for the payload, not the header. */
static final int RID_INPUT = 0x10000003;
/** {@code WNDCLASSEXW} size and the field offsets we fill, under x64. */
static final long WNDCLASS_SIZE = 80;
static final long WNDCLASS_CBSIZE = 0;
static final long WNDCLASS_WNDPROC = 8;
static final long WNDCLASS_HINSTANCE = 24;
static final long WNDCLASS_CLASSNAME = 64;
/** {@code RAWINPUTDEVICE}: usage page, usage, flags, target window. */
static final long RAWINPUTDEVICE_SIZE = 16;
static final long RAWINPUTDEVICE_USAGE_PAGE = 0;
static final long RAWINPUTDEVICE_USAGE = 2;
static final long RAWINPUTDEVICE_FLAGS = 4;
static final long RAWINPUTDEVICE_TARGET = 8;
/** {@code MSG} is 48 bytes under x64. */
static final long MSG_SIZE = 48;
/** The C signature of a {@code WNDPROC}. */
static final FunctionDescriptor WND_PROC =
FunctionDescriptor.of(LONG, PTR, INT, LONG, LONG);
private static final Linker LINKER = Linker.nativeLinker();
private static final class Libs {
static final SymbolLookup USER32 = SymbolLookup.libraryLookup("user32.dll", Arena.global());
static final SymbolLookup KERNEL32 = SymbolLookup.libraryLookup("kernel32.dll", Arena.global());
static final MethodHandle GET_MODULE_HANDLE =
downcall(KERNEL32, "GetModuleHandleW", FunctionDescriptor.of(PTR, PTR));
static final MethodHandle REGISTER_CLASS =
downcall(USER32, "RegisterClassExW", FunctionDescriptor.of(INT, PTR));
static final MethodHandle UNREGISTER_CLASS =
downcall(USER32, "UnregisterClassW", FunctionDescriptor.of(INT, PTR, PTR));
static final MethodHandle CREATE_WINDOW =
downcall(USER32, "CreateWindowExW", FunctionDescriptor.of(PTR,
INT, PTR, PTR, INT, INT, INT, INT, INT, PTR, PTR, PTR, PTR));
static final MethodHandle DESTROY_WINDOW =
downcall(USER32, "DestroyWindow", FunctionDescriptor.of(INT, PTR));
static final MethodHandle DEF_WINDOW_PROC =
downcall(USER32, "DefWindowProcW", FunctionDescriptor.of(LONG, PTR, INT, LONG, LONG));
static final MethodHandle GET_MESSAGE =
downcall(USER32, "GetMessageW", FunctionDescriptor.of(INT, PTR, PTR, INT, INT));
static final MethodHandle DISPATCH_MESSAGE =
downcall(USER32, "DispatchMessageW", FunctionDescriptor.of(LONG, PTR));
static final MethodHandle POST_MESSAGE =
downcall(USER32, "PostMessageW", FunctionDescriptor.of(INT, PTR, INT, LONG, LONG));
static final MethodHandle POST_QUIT_MESSAGE =
downcall(USER32, "PostQuitMessage", FunctionDescriptor.ofVoid(INT));
static final MethodHandle REGISTER_RAW_INPUT =
downcall(USER32, "RegisterRawInputDevices", FunctionDescriptor.of(INT, PTR, INT, INT));
static final MethodHandle GET_RAW_INPUT_DATA =
downcall(USER32, "GetRawInputData",
FunctionDescriptor.of(INT, PTR, INT, PTR, PTR, INT));
static final MethodHandle GET_KEY_NAME_TEXT =
downcall(USER32, "GetKeyNameTextW", FunctionDescriptor.of(INT, INT, PTR, INT));
}
private static MethodHandle downcall(SymbolLookup lookup, String symbol,
FunctionDescriptor descriptor) {
return LINKER.downcallHandle(
lookup.find(symbol).orElseThrow(() ->
new UnsatisfiedLinkError("unresolved symbol " + symbol)),
descriptor);
}
static boolean isWindows() {
return System.getProperty("os.name", "").toLowerCase().startsWith("windows");
}
static boolean isAvailable() {
if (!isWindows()) return false;
try {
return Libs.USER32 != null && Libs.KERNEL32 != null;
} catch (Throwable t) {
return false;
}
}
/** Allocates a null-terminated UTF-16 string, as every {@code ...W} entry point wants. */
static MemorySegment wide(Arena arena, String text) {
return arena.allocateFrom(text, StandardCharsets.UTF_16LE);
}
static MemorySegment moduleHandle() {
return (MemorySegment) call(Libs.GET_MODULE_HANDLE, MemorySegment.NULL);
}
/** @return the class atom, or 0 when registration failed */
static int registerClass(MemorySegment wndClass) {
return (int) call(Libs.REGISTER_CLASS, wndClass);
}
static void unregisterClass(MemorySegment className, MemorySegment instance) {
call(Libs.UNREGISTER_CLASS, className, instance);
}
/** Creates a message-only window: no pixels, but it has a queue and can be a target. */
static MemorySegment createMessageWindow(MemorySegment className, MemorySegment instance) {
return (MemorySegment) call(Libs.CREATE_WINDOW, 0, className, MemorySegment.NULL, 0,
0, 0, 0, 0, MemorySegment.ofAddress(HWND_MESSAGE),
MemorySegment.NULL, instance, MemorySegment.NULL);
}
static void destroyWindow(MemorySegment window) {
call(Libs.DESTROY_WINDOW, window);
}
static long defWindowProc(MemorySegment window, int message, long wParam, long lParam) {
return (long) call(Libs.DEF_WINDOW_PROC, window, message, wParam, lParam);
}
/** @return 1 for a message, 0 for {@code WM_QUIT}, -1 on error */
static int getMessage(MemorySegment message) {
return (int) call(Libs.GET_MESSAGE, message, MemorySegment.NULL, 0, 0);
}
static void dispatchMessage(MemorySegment message) {
call(Libs.DISPATCH_MESSAGE, message);
}
static void postMessage(MemorySegment window, int message, long wParam, long lParam) {
call(Libs.POST_MESSAGE, window, message, wParam, lParam);
}
static void postQuitMessage(int exitCode) {
call(Libs.POST_QUIT_MESSAGE, exitCode);
}
/** @return whether the devices were registered for background delivery */
static boolean registerRawInputDevices(MemorySegment devices, int count) {
return (int) call(Libs.REGISTER_RAW_INPUT, devices, count, (int) RAWINPUTDEVICE_SIZE) != 0;
}
/** @return bytes written into {@code buffer}, or -1 on failure */
static int getRawInputData(MemorySegment handle, MemorySegment buffer, MemorySegment size) {
return (int) call(Libs.GET_RAW_INPUT_DATA, handle, RID_INPUT, buffer, size,
(int) RawInput.HEADER_SIZE);
}
/**
* The layout's name for a key.
*
* @param lParam scan code in bits 16..23, the extended flag in bit 24, as the API wants
*/
static String keyName(int lParam, Arena arena) {
MemorySegment buffer = arena.allocate(128);
int length = (int) call(Libs.GET_KEY_NAME_TEXT, lParam, buffer, 64);
if (length <= 0) return null;
return buffer.getString(0, StandardCharsets.UTF_16LE);
}
private static Object call(MethodHandle handle, Object... args) {
try {
return handle.invokeWithArguments(args);
} catch (Throwable t) {
throw new IllegalStateException("Win32 call failed", t);
}
}
}

View File

@@ -0,0 +1,234 @@
package com.ts3client.hotkey.desktop;
import com.ts3client.hotkey.GlobalInputHook;
import com.ts3client.hotkey.HotkeyKey;
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* System-wide input hook for Windows, built on raw input: a message-only window
* registers the keyboard and mouse with {@code RIDEV_INPUTSINK}, so every key and button
* on the machine arrives as {@code WM_INPUT} whether or not this application is in front,
* and the event still reaches the foreground window untouched.
*
* <p>Chosen over a {@code WH_KEYBOARD_LL} hook because raw input only observes: it cannot
* swallow a keystroke, it does not put this process in the path of the system input queue
* — where a slow callback stalls typing everywhere — and it reports the side buttons and
* both edges of every key, which push-to-talk needs.
*
* <p>A window's messages belong to the thread that created it, so the window is created
* on the pump thread and {@link #start} waits to hear whether that succeeded.
*/
public final class WindowsInputHook implements GlobalInputHook {
private static final String WINDOW_CLASS = "Ts3jHotkeyInputSink";
/** How long to wait for the pump thread to stand its window up before giving up. */
private static final long STARTUP_TIMEOUT_SECONDS = 5;
private static final MethodHandle WND_PROC;
static {
try {
WND_PROC = MethodHandles.lookup().findStatic(WindowsInputHook.class, "onMessage",
MethodType.methodType(long.class, MemorySegment.class, int.class,
long.class, long.class));
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
/** The running hook, for the static window procedure to find its way back. */
private static volatile WindowsInputHook current;
private volatile MemorySegment window = MemorySegment.NULL;
private Thread thread;
private volatile Listener listener;
private volatile boolean running;
private volatile String unavailable = "not started";
public static boolean isSupported() {
return Win32.isAvailable();
}
@Override
public void start(Listener listener) {
this.listener = listener;
CountDownLatch ready = new CountDownLatch(1);
thread = new Thread(() -> pump(ready), "ts3j-hotkeys-rawinput");
thread.setDaemon(true);
thread.start();
try {
if (!ready.await(STARTUP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
unavailable = "the raw input window did not come up";
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
unavailable = "interrupted while starting";
}
}
/** Owns the window for its whole life: creates it, pumps it, tears it down. */
private void pump(CountDownLatch ready) {
boolean signalled = false;
try (Arena arena = Arena.ofConfined()) {
MemorySegment className = Win32.wide(arena, WINDOW_CLASS);
MemorySegment instance = Win32.moduleHandle();
MemorySegment stub = java.lang.foreign.Linker.nativeLinker()
.upcallStub(WND_PROC, Win32.WND_PROC, arena);
MemorySegment wndClass = arena.allocate(Win32.WNDCLASS_SIZE);
wndClass.set(ValueLayout.JAVA_INT, Win32.WNDCLASS_CBSIZE, (int) Win32.WNDCLASS_SIZE);
wndClass.set(ValueLayout.ADDRESS, Win32.WNDCLASS_WNDPROC, stub);
wndClass.set(ValueLayout.ADDRESS, Win32.WNDCLASS_HINSTANCE, instance);
wndClass.set(ValueLayout.ADDRESS, Win32.WNDCLASS_CLASSNAME, className);
// A leftover registration from an earlier run in this process is harmless:
// CreateWindowEx only needs the class to exist.
Win32.registerClass(wndClass);
MemorySegment hwnd = Win32.createMessageWindow(className, instance);
if (hwnd.equals(MemorySegment.NULL)) {
unavailable = "cannot create the raw input window";
return;
}
window = hwnd;
if (!Win32.registerRawInputDevices(rawInputDevices(arena, hwnd), 2)) {
unavailable = "RegisterRawInputDevices failed";
Win32.destroyWindow(hwnd);
window = MemorySegment.NULL;
return;
}
current = this;
running = true;
unavailable = "";
ready.countDown();
signalled = true;
MemorySegment message = arena.allocate(Win32.MSG_SIZE);
while (running) {
int result = Win32.getMessage(message);
// 0 is WM_QUIT, -1 an error; either way the window is finished.
if (result <= 0) break;
Win32.dispatchMessage(message);
}
} catch (Throwable t) {
unavailable = describe(t);
} finally {
running = false;
window = MemorySegment.NULL;
if (current == this) current = null;
if (!signalled) ready.countDown();
}
}
/** The keyboard and the mouse, both asked for in the background. */
private static MemorySegment rawInputDevices(Arena arena, MemorySegment hwnd) {
MemorySegment devices = arena.allocate(Win32.RAWINPUTDEVICE_SIZE * 2);
int[] usages = {Win32.USAGE_KEYBOARD, Win32.USAGE_MOUSE};
for (int i = 0; i < usages.length; i++) {
long base = i * Win32.RAWINPUTDEVICE_SIZE;
devices.set(ValueLayout.JAVA_SHORT, base + Win32.RAWINPUTDEVICE_USAGE_PAGE,
(short) Win32.USAGE_PAGE_GENERIC);
devices.set(ValueLayout.JAVA_SHORT, base + Win32.RAWINPUTDEVICE_USAGE,
(short) usages[i]);
devices.set(ValueLayout.JAVA_INT, base + Win32.RAWINPUTDEVICE_FLAGS,
Win32.RIDEV_INPUTSINK);
devices.set(ValueLayout.ADDRESS, base + Win32.RAWINPUTDEVICE_TARGET, hwnd);
}
return devices;
}
/** Upcall target: the window procedure. */
@SuppressWarnings("unused")
private static long onMessage(MemorySegment hwnd, int message, long wParam, long lParam) {
WindowsInputHook hook = current;
try {
if (message == Win32.WM_INPUT && hook != null) {
hook.readInput(lParam);
} else if (message == Win32.WM_DESTROY) {
Win32.postQuitMessage(0);
return 0;
}
} catch (Throwable ignored) {
}
// WM_INPUT must reach DefWindowProc too, so the system can release the event.
return Win32.defWindowProc(hwnd, message, wParam, lParam);
}
/** Copies one {@code RAWINPUT} out of the system and turns it into hotkey events. */
private void readInput(long handle) {
Listener l = listener;
if (l == null) return;
try (Arena arena = Arena.ofConfined()) {
MemorySegment buffer = arena.allocate(RawInput.BUFFER_SIZE);
MemorySegment size = arena.allocate(ValueLayout.JAVA_INT);
size.set(ValueLayout.JAVA_INT, 0, (int) RawInput.BUFFER_SIZE);
if (Win32.getRawInputData(MemorySegment.ofAddress(handle), buffer, size) <= 0) return;
switch (buffer.get(ValueLayout.JAVA_INT, RawInput.HEADER_TYPE)) {
case RawInput.TYPE_KEYBOARD -> {
int makeCode = buffer.get(ValueLayout.JAVA_SHORT, RawInput.KEYBOARD_MAKE_CODE) & 0xFFFF;
int flags = buffer.get(ValueLayout.JAVA_SHORT, RawInput.KEYBOARD_FLAGS) & 0xFFFF;
int vkey = buffer.get(ValueLayout.JAVA_SHORT, RawInput.KEYBOARD_VKEY) & 0xFFFF;
int code = RawInput.scanCode(makeCode, flags, vkey);
if (code >= 0) l.onInput(HotkeyKey.keyboard(code), !RawInput.isRelease(flags));
}
case RawInput.TYPE_MOUSE -> {
int flags = buffer.get(ValueLayout.JAVA_SHORT, RawInput.MOUSE_BUTTON_FLAGS) & 0xFFFF;
short data = buffer.get(ValueLayout.JAVA_SHORT, RawInput.MOUSE_BUTTON_FLAGS + 2);
for (RawInput.ButtonEvent e : RawInput.buttons(flags, data)) {
l.onInput(e.key(), e.pressed());
}
}
default -> {
}
}
} catch (Throwable ignored) {
}
}
@Override
public boolean isRunning() {
return running;
}
@Override
public String unavailableReason() {
return running ? "" : unavailable;
}
@Override
public String keyName(HotkeyKey key) {
return WindowsKeyNamer.name(key);
}
@Override
public void close() {
listener = null;
running = false;
MemorySegment hwnd = window;
try {
// Closing has to happen on the pump thread; ask it to, then let it unwind.
if (!hwnd.equals(MemorySegment.NULL)) Win32.postMessage(hwnd, Win32.WM_CLOSE, 0, 0);
if (thread != null) thread.join(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Throwable ignored) {
}
thread = null;
}
private static String describe(Throwable t) {
String message = t.getMessage();
return (message == null || message.isBlank()) ? t.getClass().getSimpleName() : message;
}
}

View File

@@ -0,0 +1,37 @@
package com.ts3client.hotkey.desktop;
import com.ts3client.hotkey.HotkeyKey;
import java.lang.foreign.Arena;
import java.util.HashMap;
import java.util.Map;
/**
* Names scan codes through the active Windows layout, so a hotkey button shows "A"
* rather than "Key 30" — the counterpart to {@link X11KeyNamer}.
*
* <p>{@code GetKeyNameTextW} wants the scan code where a {@code WM_KEYDOWN} would carry
* it: bits 16..23, with bit 24 marking the {@code E0} escape.
*/
final class WindowsKeyNamer {
private WindowsKeyNamer() {
}
private static final Map<Integer, String> CACHE = new HashMap<>();
static synchronized String name(HotkeyKey key) {
if (key.device() != HotkeyKey.Device.KEYBOARD) return null;
if (!Win32.isAvailable()) return null;
return CACHE.computeIfAbsent(key.code(), code -> {
try (Arena arena = Arena.ofConfined()) {
int lParam = (code & 0xFF) << 16;
if ((code & 0xE000) == 0xE000) lParam |= 1 << 24;
String name = Win32.keyName(lParam, arena);
return (name == null || name.isBlank()) ? null : name;
} catch (Throwable t) {
return null;
}
});
}
}

View File

@@ -0,0 +1,47 @@
package com.ts3client.hotkey.desktop;
import com.ts3client.hotkey.HotkeyKey;
import java.lang.foreign.MemorySegment;
import java.util.HashMap;
import java.util.Map;
/**
* Names keycodes using the X keyboard layout, so a hotkey button shows "A" rather than
* "Key 38". Bindings themselves stay layout-independent; only the label comes from here.
*
* <p>Keeps one long-lived display connection of its own, opened on first use, so it can
* be asked from any thread without racing the input hooks' own connections.
*/
final class X11KeyNamer {
private X11KeyNamer() {
}
private static final Map<Integer, String> CACHE = new HashMap<>();
private static MemorySegment display;
private static boolean tried;
static synchronized String name(HotkeyKey key) {
if (key.device() != HotkeyKey.Device.KEYBOARD) return null;
if (!tried) {
tried = true;
try {
if (Xlib.isCoreAvailable() && System.getenv("DISPLAY") != null) {
display = Xlib.openDisplay();
}
} catch (Throwable ignored) {
display = null;
}
}
if (display == null || display.equals(MemorySegment.NULL)) return null;
return CACHE.computeIfAbsent(key.code(), code -> {
try {
long sym = Xlib.keysym(display, code);
return sym == 0 ? null : KeyNames.pretty(Xlib.keysymName(sym));
} catch (Throwable t) {
return null;
}
});
}
}

View File

@@ -0,0 +1,206 @@
package com.ts3client.hotkey.desktop;
import com.ts3client.hotkey.GlobalInputHook;
import com.ts3client.hotkey.HotkeyKey;
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
/**
* System-wide input hook built on XInput2 raw events, the way TeamSpeak's own Linux
* client does it: {@code XI_RawKeyPress} and friends are selected on the root window, so
* every key and button on the machine arrives here regardless of which window has focus,
* and the event still reaches that window untouched.
*
* <p>Raw events report the device's own view of the key, before focus routing and before
* any layout mapping, which is precisely what a hotkey wants — the detail field is the
* keycode, matching how {@link HotkeyKey} identifies keys.
*
* <p>On at least some driver stacks, {@code XI_RawButtonRelease} is never delivered here
* at all: the X server hands the initiating {@code XI_RawButtonPress} to every raw
* listener, but the matching release only reaches whichever client holds the pointer's
* implicit per-click grab, which is never us. An active {@code XIGrabDevice} grab makes
* this hook that owner and does fix delivery, but even with {@code owner_events} set it
* stopped clicks from reaching other windows at all — worse than the bug it fixes — so it
* is not used here. {@link XRecordInputHook} is unaffected and takes over first on X11 as
* a result; this hook stays as its fallback for servers without RECORD.
*
* <p>Under Wayland this hook lives inside Xwayland and therefore sees what Xwayland is
* given: every X11 client, plus — on compositors that allow it, KWin's "legacy X11 app
* support" among them — keys aimed at native Wayland windows too. Where the compositor
* forwards nothing, {@link EvdevInputHook} is the way out.
*/
public final class XInput2InputHook implements GlobalInputHook {
/** How long the event loop sleeps on the socket before rechecking {@link #running}. */
private static final int POLL_TIMEOUT_MILLIS = 200;
private Arena arena;
private MemorySegment display = MemorySegment.NULL;
private int opcode = -1;
private Thread thread;
private volatile Listener listener;
private volatile boolean running;
private volatile String unavailable = "not started";
public static boolean isSupported() {
return Xlib.isInputAvailable() && System.getenv("DISPLAY") != null;
}
@Override
public void start(Listener listener) {
this.listener = listener;
try {
open();
} catch (Throwable t) {
unavailable = describe(t);
closeQuietly();
}
}
private void open() {
arena = Arena.ofShared();
display = Xlib.openDisplay();
if (display.equals(MemorySegment.NULL)) {
throw new IllegalStateException("cannot open the X display");
}
opcode = Xlib.queryExtension(display, "XInputExtension", arena);
if (opcode < 0) throw new IllegalStateException("the X server has no XInput extension");
if (!Xlib.queryInputVersion(display, arena)) {
throw new IllegalStateException("the X server does not speak XInput 2");
}
long root = Xlib.defaultRootWindow(display);
int maskLen = maskLen();
MemorySegment bits = rawMaskBits(maskLen);
int[] masters = Xlib.queryMasterDeviceIds(display, arena);
Xlib.selectInputEvents(display, root, masters, bits, maskLen, arena);
Xlib.sync(display);
running = true;
unavailable = "";
thread = new Thread(this::pump, "ts3j-hotkeys-xinput2");
thread.setDaemon(true);
thread.start();
}
private static int maskLen() {
// XIMaskLen(event): the byte length of a mask covering event types up to this one.
return (Xlib.XI_RAW_BUTTON_RELEASE >> 3) + 1;
}
/** The four raw event types, as a byte mask in {@code XISetMask} order. */
private MemorySegment rawMaskBits(int maskLen) {
MemorySegment bits = arena.allocate(maskLen);
for (int type : new int[]{Xlib.XI_RAW_KEY_PRESS, Xlib.XI_RAW_KEY_RELEASE,
Xlib.XI_RAW_BUTTON_PRESS, Xlib.XI_RAW_BUTTON_RELEASE}) {
long index = type / 8;
byte bit = (byte) (1 << (type % 8));
bits.set(ValueLayout.JAVA_BYTE, index,
(byte) (bits.get(ValueLayout.JAVA_BYTE, index) | bit));
}
return bits;
}
private void pump() {
try (Arena loop = Arena.ofConfined()) {
MemorySegment event = loop.allocate(Xlib.EVENT_SIZE);
MemorySegment pollFd = Xlib.pollFd(display, loop);
while (running) {
if (Xlib.pending(display) <= 0) {
Xlib.awaitEvent(pollFd, POLL_TIMEOUT_MILLIS);
continue;
}
Xlib.nextEvent(display, event);
handle(event);
}
} catch (Throwable t) {
unavailable = describe(t);
} finally {
running = false;
}
}
/** Unpacks one event, which is ours only when it is a generic event from XInput2. */
private void handle(MemorySegment event) {
if (event.get(ValueLayout.JAVA_INT, Xlib.COOKIE_TYPE) != Xlib.GENERIC_EVENT) return;
if (event.get(ValueLayout.JAVA_INT, Xlib.COOKIE_EXTENSION) != opcode) return;
if (!Xlib.getEventData(display, event)) return;
try {
int evtype = event.get(ValueLayout.JAVA_INT, Xlib.COOKIE_EVTYPE);
MemorySegment raw = event.get(ValueLayout.ADDRESS, Xlib.COOKIE_DATA);
if (raw.equals(MemorySegment.NULL)) return;
int detail = raw.reinterpret(Xlib.RAW_EVENT_SIZE)
.get(ValueLayout.JAVA_INT, Xlib.RAW_EVENT_DETAIL);
dispatch(evtype, detail);
} finally {
Xlib.freeEventData(display, event);
}
}
private void dispatch(int evtype, int detail) {
Listener l = listener;
if (l == null) return;
switch (evtype) {
case Xlib.XI_RAW_KEY_PRESS -> l.onInput(HotkeyKey.keyboard(detail), true);
case Xlib.XI_RAW_KEY_RELEASE -> l.onInput(HotkeyKey.keyboard(detail), false);
case Xlib.XI_RAW_BUTTON_PRESS -> l.onInput(HotkeyKey.mouse(detail), true);
case Xlib.XI_RAW_BUTTON_RELEASE -> l.onInput(HotkeyKey.mouse(detail), false);
default -> {
}
}
}
@Override
public boolean isRunning() {
return running;
}
@Override
public String unavailableReason() {
return running ? "" : unavailable;
}
@Override
public String keyName(HotkeyKey key) {
return X11KeyNamer.name(key);
}
@Override
public void close() {
listener = null;
running = false;
try {
if (thread != null) thread.join(POLL_TIMEOUT_MILLIS * 5L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
closeQuietly();
}
private synchronized void closeQuietly() {
// The pump owns the display connection; closing it underneath would crash Xlib.
boolean pumpDone = thread == null || !thread.isAlive();
if (pumpDone) {
try {
if (!display.equals(MemorySegment.NULL)) Xlib.closeDisplay(display);
} catch (Throwable ignored) {
}
display = MemorySegment.NULL;
if (arena != null) {
try {
arena.close();
} catch (Throwable ignored) {
}
arena = null;
}
thread = null;
}
}
private static String describe(Throwable t) {
String message = t.getMessage();
return (message == null || message.isBlank()) ? t.getClass().getSimpleName() : message;
}
}

View File

@@ -0,0 +1,212 @@
package com.ts3client.hotkey.desktop;
import com.ts3client.hotkey.GlobalInputHook;
import com.ts3client.hotkey.HotkeyKey;
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
/**
* System-wide input hook built on X11's RECORD extension: it taps the core key and
* button events the server delivers to every client, so hotkeys work no matter which
* window has focus, and the keystroke still reaches that window untouched.
*
* <p>Two connections are needed, as RECORD demands: a control one that creates and
* later tears the context down, and a data one that blocks inside
* {@code XRecordEnableContext} handing us events.
*
* <p>Under Wayland the X server only ever sees events aimed at X clients, so this hook
* is not truly global there; {@link EvdevInputHook} is the way out.
*/
public final class XRecordInputHook implements GlobalInputHook {
private static final MethodHandle CALLBACK;
static {
try {
CALLBACK = MethodHandles.lookup().findStatic(XRecordInputHook.class, "onRecorded",
MethodType.methodType(void.class, MemorySegment.class, MemorySegment.class));
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
/** The running hook, for the static upcall to find its way back. */
private static volatile XRecordInputHook current;
private Arena arena;
private MemorySegment control = MemorySegment.NULL;
private MemorySegment data = MemorySegment.NULL;
private long context;
private Thread thread;
private volatile Listener listener;
private volatile boolean running;
private volatile String unavailable = "not started";
public static boolean isSupported() {
return Xlib.isAvailable() && System.getenv("DISPLAY") != null;
}
@Override
public void start(Listener listener) {
this.listener = listener;
try {
open();
} catch (Throwable t) {
unavailable = describe(t);
closeQuietly();
}
}
private void open() {
arena = Arena.ofShared();
control = Xlib.openDisplay();
if (control.equals(MemorySegment.NULL)) {
throw new IllegalStateException("cannot open the X display");
}
if (!Xlib.queryRecordVersion(control, arena)) {
throw new IllegalStateException("the X server has no RECORD extension");
}
MemorySegment range = Xlib.allocRange();
if (range.equals(MemorySegment.NULL)) throw new IllegalStateException("XRecordAllocRange failed");
MemorySegment r = range.reinterpret(64);
r.set(ValueLayout.JAVA_BYTE, Xlib.RANGE_DEVICE_EVENTS_FIRST, (byte) Xlib.KEY_PRESS);
r.set(ValueLayout.JAVA_BYTE, Xlib.RANGE_DEVICE_EVENTS_LAST, (byte) Xlib.BUTTON_RELEASE);
MemorySegment clients = arena.allocate(ValueLayout.JAVA_LONG);
clients.set(ValueLayout.JAVA_LONG, 0, Xlib.ALL_CLIENTS);
MemorySegment ranges = arena.allocate(ValueLayout.ADDRESS);
ranges.set(ValueLayout.ADDRESS, 0, range);
context = Xlib.createContext(control, clients, 1, ranges, 1);
if (context == 0) throw new IllegalStateException("XRecordCreateContext failed");
// The context id is allocated client-side and the request is not a round trip:
// without this the second connection can enable a context the server has yet to
// create, which it answers with BadContext.
Xlib.sync(control);
data = Xlib.openDisplay();
if (data.equals(MemorySegment.NULL)) {
throw new IllegalStateException("cannot open the second X display connection");
}
MemorySegment stub = java.lang.foreign.Linker.nativeLinker()
.upcallStub(CALLBACK, Xlib.INTERCEPT_PROC, arena);
current = this;
running = true;
unavailable = "";
thread = new Thread(() -> {
try {
// Blocks until close() disables the context from the control connection.
Xlib.enableContext(data, context, stub);
} catch (Throwable ignored) {
} finally {
running = false;
}
}, "ts3j-hotkeys-xrecord");
thread.setDaemon(true);
thread.start();
}
/** Upcall target: one recorded protocol datum. */
@SuppressWarnings("unused")
private static void onRecorded(MemorySegment closure, MemorySegment recorded) {
XRecordInputHook hook = current;
MemorySegment d = recorded.reinterpret(48);
try {
if (hook == null || d.get(ValueLayout.JAVA_INT, Xlib.INTERCEPT_CATEGORY) != Xlib.FROM_SERVER) {
return;
}
long length = d.get(ValueLayout.JAVA_LONG, Xlib.INTERCEPT_DATA_LEN);
MemorySegment event = d.get(ValueLayout.ADDRESS, Xlib.INTERCEPT_DATA);
// data_len counts 4-byte units; a core event is always 32 bytes.
if (event.equals(MemorySegment.NULL) || length < 2) return;
event = event.reinterpret(32);
hook.dispatch(event.get(ValueLayout.JAVA_BYTE, 0) & 0x7f,
event.get(ValueLayout.JAVA_BYTE, 1) & 0xff);
} catch (Throwable ignored) {
} finally {
Xlib.freeData(recorded);
}
}
private void dispatch(int type, int detail) {
Listener l = listener;
if (l == null) return;
switch (type) {
case Xlib.KEY_PRESS -> l.onInput(HotkeyKey.keyboard(detail), true);
case Xlib.KEY_RELEASE -> l.onInput(HotkeyKey.keyboard(detail), false);
case Xlib.BUTTON_PRESS -> l.onInput(HotkeyKey.mouse(detail), true);
case Xlib.BUTTON_RELEASE -> l.onInput(HotkeyKey.mouse(detail), false);
default -> {
}
}
}
@Override
public boolean isRunning() {
return running;
}
@Override
public String unavailableReason() {
return running ? "" : unavailable;
}
@Override
public String keyName(HotkeyKey key) {
return X11KeyNamer.name(key);
}
@Override
public void close() {
listener = null;
running = false;
try {
if (!control.equals(MemorySegment.NULL) && context != 0) {
Xlib.disableContext(control, context);
Xlib.flush(control);
}
if (thread != null) thread.join(1000);
} catch (Throwable ignored) {
}
closeQuietly();
}
private synchronized void closeQuietly() {
if (current == this) current = null;
try {
if (!control.equals(MemorySegment.NULL) && context != 0) Xlib.freeContext(control, context);
} catch (Throwable ignored) {
}
context = 0;
for (MemorySegment display : new MemorySegment[]{data, control}) {
try {
if (!display.equals(MemorySegment.NULL)) Xlib.closeDisplay(display);
} catch (Throwable ignored) {
}
}
data = control = MemorySegment.NULL;
// The arena owns the upcall stub: freeing it while the recording thread could
// still call into it would take the JVM down, so a stuck thread keeps it alive.
if (arena != null && (thread == null || !thread.isAlive())) {
try {
arena.close();
} catch (Throwable ignored) {
}
arena = null;
}
thread = null;
}
private static String describe(Throwable t) {
String message = t.getMessage();
return (message == null || message.isBlank()) ? t.getClass().getSimpleName() : message;
}
}

View File

@@ -0,0 +1,400 @@
package com.ts3client.hotkey.desktop;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
import java.nio.charset.StandardCharsets;
/**
* Raw binding to {@code libX11}, the XInput2 extension in {@code libXi} and the RECORD
* extension in {@code libXtst}, with just the calls the X11 hooks need.
*
* <p>Both extensions let a client watch input the server delivers to everyone else
* without intercepting it, which is exactly what a global hotkey needs: the keystroke
* still reaches the focused application. See {@link DesktopInputHooks#addX11Hooks} for
* which one is preferred and why.
*
* <p>Each library loads lazily and failure is expected — with no X11 around, the
* {@code is*Available()} probes return {@code false} and the caller falls back to
* another backend.
*/
final class Xlib {
private Xlib() {
}
static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT;
static final ValueLayout.OfLong LONG = ValueLayout.JAVA_LONG;
private static final java.lang.foreign.AddressLayout PTR = ValueLayout.ADDRESS;
/** Core event types (X.h) as they appear in the recorded protocol data. */
static final int KEY_PRESS = 2;
static final int KEY_RELEASE = 3;
static final int BUTTON_PRESS = 4;
static final int BUTTON_RELEASE = 5;
/** {@code XRecordAllClients}: record every client, present and future. */
static final long ALL_CLIENTS = 3L;
/** {@code XRecordInterceptData.category} values we care about. */
static final int FROM_SERVER = 0;
/**
* {@code XRecordRange}: only {@code device_events} is filled in. Its two bytes sit
* after the request/reply and delivered-event ranges, at a fixed LP64 offset.
*/
static final long RANGE_DEVICE_EVENTS_FIRST = 18;
static final long RANGE_DEVICE_EVENTS_LAST = 19;
/** {@code XRecordInterceptData}: the protocol bytes and the reason we were called. */
static final long INTERCEPT_CATEGORY = 24;
static final long INTERCEPT_DATA = 32;
static final long INTERCEPT_DATA_LEN = 40;
/** {@code GenericEvent}, the envelope every extension event arrives in. */
static final int GENERIC_EVENT = 35;
/** XInput2 raw event types (XI2.h): the device's own view, before any focus routing. */
static final int XI_RAW_KEY_PRESS = 13;
static final int XI_RAW_KEY_RELEASE = 14;
static final int XI_RAW_BUTTON_PRESS = 15;
static final int XI_RAW_BUTTON_RELEASE = 16;
/** {@code XIAllMasterDevices}: raw events reach us through the master devices. */
static final int XI_ALL_MASTER_DEVICES = 1;
/** An {@code XEvent} is a union of 24 longs; a cookie is the widest member we read. */
static final long EVENT_SIZE = 192;
/** {@code XGenericEventCookie} field offsets under LP64. */
static final long COOKIE_TYPE = 0;
static final long COOKIE_EXTENSION = 32;
static final long COOKIE_EVTYPE = 36;
static final long COOKIE_DATA = 48;
/**
* {@code XIRawEvent.detail} — the keycode or button number — past the generic header,
* {@code time}, {@code deviceid} and {@code sourceid}.
*/
static final long RAW_EVENT_DETAIL = 56;
static final long RAW_EVENT_SIZE = 152;
/** {@code XIEventMask}: {@code deviceid}, {@code mask_len}, then the mask pointer. */
static final long EVENT_MASK_SIZE = 16;
static final long EVENT_MASK_DEVICEID = 0;
static final long EVENT_MASK_LEN = 4;
static final long EVENT_MASK_MASK = 8;
/** {@code XIDeviceInfo.deviceid} — the only field {@link #queryMasterDeviceIds} needs. */
static final long DEVICE_INFO_SIZE = 40;
static final long DEVICE_INFO_DEVICEID = 0;
private static final Linker LINKER = Linker.nativeLinker();
private static final String[] X11_NAMES = {"libX11.so.6", "libX11.so"};
private static final String[] XI_NAMES = {"libXi.so.6", "libXi.so"};
private static final String[] XTST_NAMES = {"libXtst.so.6", "libXtst.so"};
/** libX11 alone: enough to open a display and name keys. */
private static final class Core {
static final SymbolLookup LIB = load(X11_NAMES, "libX11");
static final MethodHandle X_OPEN_DISPLAY =
downcall(LIB, "XOpenDisplay", FunctionDescriptor.of(PTR, PTR));
static final MethodHandle X_CLOSE_DISPLAY =
downcall(LIB, "XCloseDisplay", FunctionDescriptor.of(INT, PTR));
static final MethodHandle X_FLUSH =
downcall(LIB, "XFlush", FunctionDescriptor.of(INT, PTR));
static final MethodHandle X_SYNC =
downcall(LIB, "XSync", FunctionDescriptor.of(INT, PTR, INT));
static final MethodHandle XKB_KEYCODE_TO_KEYSYM =
downcall(LIB, "XkbKeycodeToKeysym", FunctionDescriptor.of(LONG, PTR, INT, INT, INT));
static final MethodHandle X_KEYSYM_TO_STRING =
downcall(LIB, "XKeysymToString", FunctionDescriptor.of(PTR, LONG));
static final MethodHandle X_QUERY_EXTENSION =
downcall(LIB, "XQueryExtension", FunctionDescriptor.of(INT, PTR, PTR, PTR, PTR, PTR));
static final MethodHandle X_DEFAULT_ROOT_WINDOW =
downcall(LIB, "XDefaultRootWindow", FunctionDescriptor.of(LONG, PTR));
static final MethodHandle X_CONNECTION_NUMBER =
downcall(LIB, "XConnectionNumber", FunctionDescriptor.of(INT, PTR));
static final MethodHandle X_PENDING =
downcall(LIB, "XPending", FunctionDescriptor.of(INT, PTR));
static final MethodHandle X_NEXT_EVENT =
downcall(LIB, "XNextEvent", FunctionDescriptor.of(INT, PTR, PTR));
static final MethodHandle X_GET_EVENT_DATA =
downcall(LIB, "XGetEventData", FunctionDescriptor.of(INT, PTR, PTR));
static final MethodHandle X_FREE_EVENT_DATA =
downcall(LIB, "XFreeEventData", FunctionDescriptor.ofVoid(PTR, PTR));
}
/** XInput2, which ships separately in libXi. */
private static final class Input {
static final SymbolLookup LIB = load(XI_NAMES, "libXi");
static final MethodHandle XI_QUERY_VERSION =
downcall(LIB, "XIQueryVersion", FunctionDescriptor.of(INT, PTR, PTR, PTR));
static final MethodHandle XI_SELECT_EVENTS =
downcall(LIB, "XISelectEvents", FunctionDescriptor.of(INT, PTR, LONG, PTR, INT));
static final MethodHandle XI_QUERY_DEVICE =
downcall(LIB, "XIQueryDevice", FunctionDescriptor.of(PTR, PTR, INT, PTR));
static final MethodHandle XI_FREE_DEVICE_INFO =
downcall(LIB, "XIFreeDeviceInfo", FunctionDescriptor.ofVoid(PTR));
}
/** {@code poll(2)}, so the event loop can wait on the X socket and still be woken. */
private static final class Poll {
static final MethodHandle POLL = LINKER.downcallHandle(
LINKER.defaultLookup().find("poll").orElseThrow(() ->
new UnsatisfiedLinkError("unresolved symbol poll")),
FunctionDescriptor.of(INT, PTR, LONG, INT));
}
/** {@code struct pollfd} is an int and two shorts, and {@code POLLIN} is bit 0. */
private static final long POLLFD_SIZE = 8;
private static final long POLLFD_EVENTS = 4;
private static final short POLLIN = 1;
/** The RECORD extension, which ships separately in libXtst. */
private static final class Record {
static final SymbolLookup LIB = load(XTST_NAMES, "libXtst");
static final MethodHandle QUERY_VERSION =
downcall(LIB, "XRecordQueryVersion", FunctionDescriptor.of(INT, PTR, PTR, PTR));
static final MethodHandle ALLOC_RANGE =
downcall(LIB, "XRecordAllocRange", FunctionDescriptor.of(PTR));
static final MethodHandle CREATE_CONTEXT =
downcall(LIB, "XRecordCreateContext",
FunctionDescriptor.of(LONG, PTR, INT, PTR, INT, PTR, INT));
static final MethodHandle ENABLE_CONTEXT =
downcall(LIB, "XRecordEnableContext", FunctionDescriptor.of(INT, PTR, LONG, PTR, PTR));
static final MethodHandle DISABLE_CONTEXT =
downcall(LIB, "XRecordDisableContext", FunctionDescriptor.of(INT, PTR, LONG));
static final MethodHandle FREE_CONTEXT =
downcall(LIB, "XRecordFreeContext", FunctionDescriptor.of(INT, PTR, LONG));
static final MethodHandle FREE_DATA =
downcall(LIB, "XRecordFreeData", FunctionDescriptor.ofVoid(PTR));
}
private static SymbolLookup load(String[] names, String what) {
IllegalArgumentException last = null;
for (String name : names) {
try {
return SymbolLookup.libraryLookup(name, Arena.global());
} catch (IllegalArgumentException e) {
last = e;
}
}
throw (last != null) ? last : new IllegalArgumentException(what + " not found");
}
private static MethodHandle downcall(SymbolLookup lookup, String symbol,
FunctionDescriptor descriptor) {
return LINKER.downcallHandle(
lookup.find(symbol).orElseThrow(() ->
new UnsatisfiedLinkError("unresolved symbol " + symbol)),
descriptor);
}
/** The C signature of {@code XRecordInterceptProc}. */
static final FunctionDescriptor INTERCEPT_PROC = FunctionDescriptor.ofVoid(PTR, PTR);
/** Whether libX11 loaded, which is all that naming keys needs. */
static boolean isCoreAvailable() {
try {
return Core.LIB != null;
} catch (Throwable t) {
return false;
}
}
static boolean isAvailable() {
try {
return Core.LIB != null && Record.LIB != null;
} catch (Throwable t) {
return false;
}
}
/** Whether libX11 and libXi both loaded, which is what the XInput2 hook needs. */
static boolean isInputAvailable() {
try {
return Core.LIB != null && Input.LIB != null && Poll.POLL != null;
} catch (Throwable t) {
return false;
}
}
static MemorySegment openDisplay() {
return (MemorySegment) call(Core.X_OPEN_DISPLAY, MemorySegment.NULL);
}
static void closeDisplay(MemorySegment display) {
call(Core.X_CLOSE_DISPLAY, display);
}
static void flush(MemorySegment display) {
call(Core.X_FLUSH, display);
}
/** Flushes and waits for the server to have processed everything sent so far. */
static void sync(MemorySegment display) {
call(Core.X_SYNC, display, 0);
}
/** @return whether the server has the RECORD extension */
static boolean queryRecordVersion(MemorySegment display, Arena arena) {
MemorySegment major = arena.allocate(INT);
MemorySegment minor = arena.allocate(INT);
return (int) call(Record.QUERY_VERSION, display, major, minor) != 0;
}
static MemorySegment allocRange() {
return (MemorySegment) call(Record.ALLOC_RANGE);
}
static long createContext(MemorySegment display, MemorySegment clients, int clientCount,
MemorySegment ranges, int rangeCount) {
return (long) call(Record.CREATE_CONTEXT, display, 0, clients, clientCount, ranges, rangeCount);
}
static int enableContext(MemorySegment display, long context, MemorySegment callback) {
return (int) call(Record.ENABLE_CONTEXT, display, context, callback, MemorySegment.NULL);
}
static void disableContext(MemorySegment display, long context) {
call(Record.DISABLE_CONTEXT, display, context);
}
static void freeContext(MemorySegment display, long context) {
call(Record.FREE_CONTEXT, display, context);
}
static void freeData(MemorySegment data) {
call(Record.FREE_DATA, data);
}
/** @return the extension's major opcode, or -1 when the server does not have it */
static int queryExtension(MemorySegment display, String name, Arena arena) {
MemorySegment opcode = arena.allocate(INT);
MemorySegment event = arena.allocate(INT);
MemorySegment error = arena.allocate(INT);
int found = (int) call(Core.X_QUERY_EXTENSION, display, arena.allocateFrom(name),
opcode, event, error);
return found != 0 ? opcode.get(INT, 0) : -1;
}
/** @return whether the server speaks at least XInput 2.0 */
static boolean queryInputVersion(MemorySegment display, Arena arena) {
MemorySegment major = arena.allocate(INT);
MemorySegment minor = arena.allocate(INT);
major.set(INT, 0, 2);
minor.set(INT, 0, 0);
// Success is 0; anything else means the server would not agree on 2.x.
return (int) call(Input.XI_QUERY_VERSION, display, major, minor) == 0;
}
static long defaultRootWindow(MemorySegment display) {
return (long) call(Core.X_DEFAULT_ROOT_WINDOW, display);
}
/**
* Asks for the events set in {@code bits} on {@code window}, once per device in
* {@code deviceIds}: selecting against {@code XIAllMasterDevices} itself does not
* reliably deliver every raw event type, so callers resolve it to real device ids first
* with {@link #queryMasterDeviceIds}.
*/
static void selectInputEvents(MemorySegment display, long window, int[] deviceIds,
MemorySegment bits, int maskLen, Arena arena) {
MemorySegment masks = arena.allocate(EVENT_MASK_SIZE * deviceIds.length);
for (int i = 0; i < deviceIds.length; i++) {
long base = i * EVENT_MASK_SIZE;
masks.set(INT, base + EVENT_MASK_DEVICEID, deviceIds[i]);
masks.set(INT, base + EVENT_MASK_LEN, maskLen);
masks.set(PTR, base + EVENT_MASK_MASK, bits);
}
call(Input.XI_SELECT_EVENTS, display, window, masks, deviceIds.length);
}
/**
* The real device ids behind the {@code XIAllMasterDevices} pseudo-device — selecting
* raw events against each one individually, rather than the pseudo-device itself, is
* the more correct form (some drivers reportedly need it), even though it alone does
* not fix the {@code XI_RawButtonRelease} gap {@link XInput2InputHook} documents.
*/
static int[] queryMasterDeviceIds(MemorySegment display, Arena arena) {
MemorySegment count = arena.allocate(INT);
MemorySegment infos = (MemorySegment) call(Input.XI_QUERY_DEVICE, display,
XI_ALL_MASTER_DEVICES, count);
int n = count.get(INT, 0);
if (infos.equals(MemorySegment.NULL) || n <= 0) return new int[0];
try {
MemorySegment array = infos.reinterpret(DEVICE_INFO_SIZE * n);
int[] ids = new int[n];
for (int i = 0; i < n; i++) {
ids[i] = array.get(INT, i * DEVICE_INFO_SIZE + DEVICE_INFO_DEVICEID);
}
return ids;
} finally {
call(Input.XI_FREE_DEVICE_INFO, infos);
}
}
static int pending(MemorySegment display) {
return (int) call(Core.X_PENDING, display);
}
static void nextEvent(MemorySegment display, MemorySegment event) {
call(Core.X_NEXT_EVENT, display, event);
}
/** Fetches a generic event's payload; the caller must free it when this succeeds. */
static boolean getEventData(MemorySegment display, MemorySegment cookie) {
return (int) call(Core.X_GET_EVENT_DATA, display, cookie) != 0;
}
static void freeEventData(MemorySegment display, MemorySegment cookie) {
call(Core.X_FREE_EVENT_DATA, display, cookie);
}
/**
* A {@code struct pollfd} watching the display connection, to be allocated once and
* handed to {@link #awaitEvent} for the life of an event loop.
*/
static MemorySegment pollFd(MemorySegment display, Arena arena) {
MemorySegment fds = arena.allocate(POLLFD_SIZE);
fds.set(INT, 0, (int) call(Core.X_CONNECTION_NUMBER, display));
fds.set(ValueLayout.JAVA_SHORT, POLLFD_EVENTS, POLLIN);
return fds;
}
/**
* Waits until the connection has something to read or the timeout runs out, so an
* event loop can stay asleep yet still notice it has been asked to stop.
*/
static void awaitEvent(MemorySegment pollFd, int timeoutMillis) {
call(Poll.POLL, pollFd, 1L, timeoutMillis);
}
/** The unshifted keysym of a keycode in the first group, or 0 when unbound. */
static long keysym(MemorySegment display, int keycode) {
return (long) call(Core.XKB_KEYCODE_TO_KEYSYM, display, keycode, 0, 0);
}
/** The keysym's X name ("a", "Control_L", "F5"), or {@code null}. */
static String keysymName(long keysym) {
MemorySegment name = (MemorySegment) call(Core.X_KEYSYM_TO_STRING, keysym);
if (name == null || name.equals(MemorySegment.NULL)) return null;
return name.reinterpret(Long.MAX_VALUE).getString(0, StandardCharsets.US_ASCII);
}
private static Object call(MethodHandle handle, Object... args) {
try {
return handle.invokeWithArguments(args);
} catch (Throwable t) {
throw new IllegalStateException("X11 call failed", t);
}
}
}

View File

@@ -0,0 +1,92 @@
package com.ts3client.hotkey.desktop;
import com.ts3client.hotkey.HotkeyKey;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Decoding of Windows raw input, which needs no Windows to check. */
class RawInputTest {
@Test
void plainKeyKeepsItsScanCode() {
// 'A' is scan code 0x1E, no escape.
assertEquals(0x1E, RawInput.scanCode(0x1E, 0, 0x41));
}
@Test
void escapedKeysStayDistinctFromTheirUnescapedTwins() {
// Left Ctrl is 1D; right Ctrl is E0 1D, and the two must not collide.
int left = RawInput.scanCode(0x1D, 0, 0xA2);
int right = RawInput.scanCode(0x1D, RawInput.RI_KEY_E0, 0xA3);
assertEquals(0x1D, left);
assertEquals(0xE01D, right);
}
@Test
void pauseUsesTheOtherEscape() {
assertEquals(0xE11D, RawInput.scanCode(0x1D, RawInput.RI_KEY_E1, 0x13));
}
@Test
void fillerHalfOfAnEscapedSequenceIsIgnored() {
assertEquals(-1, RawInput.scanCode(0x2A, RawInput.RI_KEY_E0, 0xFF));
assertEquals(-1, RawInput.scanCode(0, 0, 0x41));
}
@Test
void breakFlagMarksTheRelease() {
assertFalse(RawInput.isRelease(0));
assertTrue(RawInput.isRelease(RawInput.RI_KEY_BREAK));
assertTrue(RawInput.isRelease(RawInput.RI_KEY_BREAK | RawInput.RI_KEY_E0));
}
@Test
void sideButtonsMapToTheNumbersUsersKnow() {
// X1 down: "Mouse 4" everywhere else in the client, X button 8.
List<RawInput.ButtonEvent> down = RawInput.buttons(0x0040, (short) 0);
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(8), true)), down);
assertEquals("Mouse 4", HotkeyKey.mouse(8).fallbackName());
List<RawInput.ButtonEvent> up = RawInput.buttons(0x0200, (short) 0);
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(9), false)), up);
assertEquals("Mouse 5", HotkeyKey.mouse(9).fallbackName());
}
@Test
void primaryButtonsUseTheXNumbering() {
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(1), true)),
RawInput.buttons(0x0001, (short) 0));
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(2), true)),
RawInput.buttons(0x0010, (short) 0));
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(3), true)),
RawInput.buttons(0x0004, (short) 0));
}
@Test
void oneEventCanCarrySeveralTransitions() {
// Left up and right down in the same report.
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(1), false),
new RawInput.ButtonEvent(HotkeyKey.mouse(3), true)),
RawInput.buttons(0x0002 | 0x0004, (short) 0));
}
@Test
void wheelBecomesATapSoTheEngineSeesBothEdges() {
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(4), true),
new RawInput.ButtonEvent(HotkeyKey.mouse(4), false)),
RawInput.buttons(0x0400, (short) 120));
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(5), true),
new RawInput.ButtonEvent(HotkeyKey.mouse(5), false)),
RawInput.buttons(0x0400, (short) -120));
}
@Test
void mouseMovementAloneReportsNothing() {
assertEquals(List.of(), RawInput.buttons(0, (short) 0));
}
}

View File

@@ -22,6 +22,8 @@
<maven.compiler.release>26</maven.compiler.release> <maven.compiler.release>26</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<ts3j.version>1.0.3</ts3j.version> <ts3j.version>1.0.3</ts3j.version>
<jsvg.version>2.1.0</jsvg.version>
<flatlaf.version>3.7.2</flatlaf.version>
<surefire.version>3.5.6</surefire.version> <surefire.version>3.5.6</surefire.version>
</properties> </properties>
@@ -44,6 +46,16 @@
<artifactId>ts3j</artifactId> <artifactId>ts3j</artifactId>
<version>${ts3j.version}</version> <version>${ts3j.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.github.weisj</groupId>
<artifactId>jsvg</artifactId>
<version>${jsvg.version}</version>
</dependency>
<dependency>
<groupId>com.formdev</groupId>
<artifactId>flatlaf</artifactId>
<version>${flatlaf.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.ts3client</groupId> <groupId>com.ts3client</groupId>
<artifactId>ts3-client-core</artifactId> <artifactId>ts3-client-core</artifactId>

View File

@@ -31,6 +31,20 @@
<groupId>com.github.manevolent</groupId> <groupId>com.github.manevolent</groupId>
<artifactId>ts3j</artifactId> <artifactId>ts3j</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.github.weisj</groupId>
<artifactId>jsvg</artifactId>
</dependency>
<dependency>
<groupId>com.formdev</groupId>
<artifactId>flatlaf</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.13.4</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -66,6 +80,23 @@
<include>**</include> <include>**</include>
</includes> </includes>
</filter> </filter>
<!-- JSVG resolves its node types by service loader/reflection,
which minimizeJar cannot follow. -->
<filter>
<artifact>com.github.weisj:jsvg</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<!-- FlatLaf instantiates its UI delegates by name and reads its
themes from .properties resources; neither is reachable
statically, so minimizeJar must not touch it. -->
<filter>
<artifact>com.formdev:flatlaf</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter> <filter>
<artifact>*:*</artifact> <artifact>*:*</artifact>
<excludes> <excludes>

View File

@@ -1,14 +1,15 @@
package com.ts3client; package com.ts3client;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.ui.IconTheme;
import com.ts3client.ui.LookAndFeelManager;
import com.ts3client.ui.MainFrame; import com.ts3client.ui.MainFrame;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.UIManager;
/** /**
* Application entry point. Loads settings, applies the system look-and-feel and * Application entry point. Loads settings, installs the look-and-feel and shows the
* shows the main window on the Swing event dispatch thread. * main window on the Swing event dispatch thread.
*/ */
public final class Main { public final class Main {
@@ -22,11 +23,8 @@ public final class Main {
final Settings settings = Settings.load(); final Settings settings = Settings.load();
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
try { LookAndFeelManager.install(settings.appearance);
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); IconTheme.get().reload(settings);
} catch (Exception ignored) {
// fall back to cross-platform L&F
}
new MainFrame(settings).setVisible(true); new MainFrame(settings).setVisible(true);
}); });
} }

View File

@@ -0,0 +1,148 @@
package com.ts3client.ui;
import com.ts3client.config.AwayMessages;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.ListSelectionModel;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Frame;
/**
* Editor for the away-message presets: a plain list of entries that are renamed
* by double-clicking them, plus Add and Remove.
*
* <p>Backed by a one-column table because that is what gives Swing's list look
* inline editing for free. Every change is written straight through to the
* {@link AwayMessages} store.
*/
public final class AwayMessagesDialog extends JDialog {
private final AwayMessages messages;
private final Runnable onChanged;
private final Model model = new Model();
private final JTable table = new JTable(model);
public AwayMessagesDialog(Frame owner, AwayMessages messages, Runnable onChanged) {
super(owner, "Away Message Presets", true);
this.messages = messages;
this.onChanged = onChanged;
table.setTableHeader(null);
table.setShowGrid(false);
table.setFillsViewportHeight(true);
table.setRowHeight(Math.max(20, table.getRowHeight()));
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
// Single click selects, double click starts editing — like a renameable list.
table.putClientProperty("JTable.autoStartsEdit", Boolean.FALSE);
JScrollPane scroll = new JScrollPane(table);
scroll.setBorder(BorderFactory.createEmptyBorder(8, 8, 4, 8));
getContentPane().setLayout(new BorderLayout());
getContentPane().add(scroll, BorderLayout.CENTER);
getContentPane().add(buildButtons(), BorderLayout.SOUTH);
Dialogs.closeOnEscape(this, this::closeDialog);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(360, 300));
setLocationRelativeTo(owner);
}
private JPanel buildButtons() {
JPanel buttons = new JPanel();
buttons.setLayout(new BoxLayout(buttons, BoxLayout.X_AXIS));
buttons.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8));
addButton(buttons, "Add", this::addEntry);
addButton(buttons, "Remove", this::removeSelected);
buttons.add(Box.createHorizontalGlue());
addButton(buttons, "Close", this::closeDialog);
return buttons;
}
private void addButton(JPanel panel, String text, Runnable action) {
JButton b = new JButton(text);
b.addActionListener(e -> action.run());
panel.add(b);
panel.add(Box.createHorizontalStrut(4));
}
private void addEntry() {
stopEditing();
messages.add("New away message");
int row = messages.all().size() - 1;
model.fireTableRowsInserted(row, row);
table.setRowSelectionInterval(row, row);
table.scrollRectToVisible(table.getCellRect(row, 0, true));
persist();
table.editCellAt(row, 0);
if (table.getEditorComponent() != null) table.getEditorComponent().requestFocusInWindow();
}
private void removeSelected() {
stopEditing();
int row = table.getSelectedRow();
if (row < 0) return;
messages.remove(row);
model.fireTableRowsDeleted(row, row);
if (!messages.all().isEmpty()) {
int next = Math.min(row, messages.all().size() - 1);
table.setRowSelectionInterval(next, next);
}
persist();
}
private void closeDialog() {
stopEditing();
dispose();
}
/** Flushes a cell that is still being edited so its text is not lost. */
private void stopEditing() {
if (table.isEditing()) table.getCellEditor().stopCellEditing();
}
private void persist() {
messages.save();
if (onChanged != null) onChanged.run();
}
private final class Model extends AbstractTableModel {
@Override
public int getRowCount() {
return messages.all().size();
}
@Override
public int getColumnCount() {
return 1;
}
@Override
public Object getValueAt(int row, int column) {
return messages.all().get(row);
}
@Override
public boolean isCellEditable(int row, int column) {
return true;
}
@Override
public void setValueAt(Object value, int row, int column) {
String text = value == null ? "" : value.toString().trim();
if (text.isEmpty()) return; // an emptied entry keeps its old text
messages.set(row, text);
persist();
}
}
}

View File

@@ -0,0 +1,113 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JDialog;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Frame;
/**
* Asks for a ban's reason and duration, like the official client's ban dialog:
* either a number of seconds, minutes, hours or days, or permanent.
*/
final class BanDialog extends JDialog {
/** The longest reason the protocol carries with a ban. */
private static final int REASON_LIMIT = 80;
/**
* The duration units the dialog offers, with the seconds each one is worth.
* The last entry is the permanent ban, which has no length and so takes no amount.
*/
private static final String[] UNIT_NAMES = {"Seconds", "Minutes", "Hours", "Days", "Permanent"};
private static final long[] UNIT_SECONDS = {1, 60, 3600, 86400, 0};
private final JTextField reasonField = ReasonDialog.reasonField(REASON_LIMIT);
private final JSpinner amount = new JSpinner(new SpinnerNumberModel(30, 1, 999999, 1));
private final JComboBox<String> unit = new JComboBox<>(UNIT_NAMES);
private boolean confirmed;
BanDialog(Frame owner, String nickname) {
super(owner, "Ban Client", true);
unit.setSelectedIndex(1); // minutes
unit.addActionListener(e -> amount.setEnabled(!isPermanent()));
JPanel form = new JPanel();
form.setLayout(new BoxLayout(form, BoxLayout.Y_AXIS));
form.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12));
form.add(ReasonDialog.label("Ban " + nickname + " from the server."));
form.add(Box.createVerticalStrut(8));
form.add(ReasonDialog.label("Reason:"));
form.add(reasonField);
form.add(Box.createVerticalStrut(8));
form.add(ReasonDialog.label("Duration:"));
form.add(durationRow());
getContentPane().setLayout(new BorderLayout());
getContentPane().add(form, BorderLayout.CENTER);
getContentPane().add(buttons(), BorderLayout.SOUTH);
ReasonDialog.focusWhenShown(reasonField);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
pack();
setResizable(false);
setLocationRelativeTo(owner);
}
private JPanel durationRow() {
JPanel row = new JPanel();
row.setLayout(new BoxLayout(row, BoxLayout.X_AXIS));
row.setAlignmentX(Component.LEFT_ALIGNMENT);
row.add(amount);
row.add(Box.createHorizontalStrut(4));
row.add(unit);
row.add(Box.createHorizontalGlue());
ReasonDialog.fixHeight(row);
return row;
}
private JPanel buttons() {
JPanel panel = new JPanel(new BorderLayout());
JPanel right = new JPanel();
JButton ok = new JButton("Ban");
JButton cancel = new JButton("Cancel");
ok.addActionListener(e -> {
confirmed = true;
dispose();
});
cancel.addActionListener(e -> dispose());
right.add(ok);
right.add(cancel);
panel.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(ok);
return panel;
}
private boolean isPermanent() {
return UNIT_SECONDS[unit.getSelectedIndex()] == 0;
}
boolean isConfirmed() {
return confirmed;
}
String getReason() {
return reasonField.getText().trim();
}
/** The ban's length in seconds, or 0 for a permanent ban. */
long getSeconds() {
return ((Number) amount.getValue()).longValue() * UNIT_SECONDS[unit.getSelectedIndex()];
}
}

View File

@@ -88,6 +88,7 @@ public final class BookmarksDialog extends JDialog {
if (!listModel.isEmpty()) list.setSelectedIndex(0); if (!listModel.isEmpty()) list.setSelectedIndex(0);
else showBookmark(null); else showBookmark(null);
Dialogs.closeOnEscape(this, this::closeDialog);
setDefaultCloseOperation(DISPOSE_ON_CLOSE); setDefaultCloseOperation(DISPOSE_ON_CLOSE);
addWindowListener(new java.awt.event.WindowAdapter() { addWindowListener(new java.awt.event.WindowAdapter() {
@Override @Override

View File

@@ -0,0 +1,200 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelSettings;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
/**
* The channel editor's "Advanced" tab: the phonetic name, the delete delay of a temporary
* channel, voice encryption, and the two client limits.
*/
final class ChannelAdvancedPanel extends JPanel implements ChannelDialog.Tab {
/** TeamSpeak's ceiling for {@code channel_delete_delay}, in seconds. */
private static final int MAX_DELETE_DELAY = 604800;
private final JTextField phoneticName = new JTextField();
private final JSpinner deleteDelay =
Spinners.compact(new JSpinner(new SpinnerNumberModel(0, 0, MAX_DELETE_DELAY, 1)));
private final JButton deleteDelayMax = new JButton("max");
private final JCheckBox encrypted = new JCheckBox("Voice Data encrypted");
private final JRadioButton maxUsersUnlimited = new JRadioButton("Unlimited");
private final JRadioButton maxUsersLimited = new JRadioButton("Limited");
private final JSpinner maxUsers = Spinners.compact(new JSpinner(new SpinnerNumberModel(16, 0, 65535, 1)));
private final JRadioButton familyInherited = new JRadioButton("Inherited");
private final JRadioButton familyUnlimited = new JRadioButton("Unlimited");
private final JRadioButton familyLimited = new JRadioButton("Limited");
private final JSpinner familyMaxUsers =
Spinners.compact(new JSpinner(new SpinnerNumberModel(16, 0, 65535, 1)));
ChannelAdvancedPanel() {
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
phoneticName.setToolTipText("Set phonetic nickname used for this channel by text-to-speech.");
deleteDelay.setToolTipText("<html><p>Delay in seconds after temporary channels are deleted"
+ " after the last user left the channel.</p></html>");
deleteDelayMax.setToolTipText("Set delete delay to maximum value");
deleteDelayMax.addActionListener(a -> deleteDelay.setValue(MAX_DELETE_DELAY));
encrypted.setToolTipText("Voice data in this channel will be encrypted.");
ButtonGroup users = new ButtonGroup();
users.add(maxUsersUnlimited);
users.add(maxUsersLimited);
maxUsersUnlimited.setToolTipText("Unlimited number of clients can join this channel.");
maxUsersLimited.setToolTipText("Limit number of clients in this channel.");
maxUsersUnlimited.addActionListener(a -> syncLimits());
maxUsersLimited.addActionListener(a -> syncLimits());
ButtonGroup family = new ButtonGroup();
family.add(familyInherited);
family.add(familyUnlimited);
family.add(familyLimited);
familyInherited.setToolTipText("Inherit the client limit by parent channel.");
familyUnlimited.setToolTipText("The channel subtree can be joined by unlimited number of clients.");
familyLimited.setToolTipText("Limit the number of clients in the channel subtree.");
for (JRadioButton radio : new JRadioButton[]{familyInherited, familyUnlimited, familyLimited}) {
radio.addActionListener(a -> syncLimits());
}
JPanel other = otherSettings();
other.setAlignmentX(0f);
fixHeight(other);
add(other);
add(Box.createVerticalStrut(6));
JPanel limits = new JPanel();
limits.setLayout(new BoxLayout(limits, BoxLayout.X_AXIS));
limits.add(limitBox("Max Users", new JRadioButton[]{maxUsersUnlimited, maxUsersLimited}, maxUsers));
limits.add(Box.createHorizontalStrut(8));
limits.add(limitBox("Family Max Users",
new JRadioButton[]{familyInherited, familyUnlimited, familyLimited}, familyMaxUsers));
limits.setAlignmentX(0f);
fixHeight(limits);
add(limits);
add(Box.createVerticalGlue());
}
/** The delete delay only means anything for a channel that disappears on its own. */
void setChannelType(ChannelSettings.Type type) {
boolean temporary = type == ChannelSettings.Type.TEMPORARY;
deleteDelay.setEnabled(temporary);
deleteDelayMax.setEnabled(temporary);
}
@Override
public void read(ChannelSettings settings) {
phoneticName.setText(settings.phoneticName);
deleteDelay.setValue(Math.min(MAX_DELETE_DELAY, Math.max(0, settings.deleteDelay)));
encrypted.setSelected(settings.encrypted);
if (settings.maxClientsUnlimited) maxUsersUnlimited.setSelected(true);
else maxUsersLimited.setSelected(true);
maxUsers.setValue(settings.maxClients);
if (settings.familyInherited) familyInherited.setSelected(true);
else if (settings.familyUnlimited) familyUnlimited.setSelected(true);
else familyLimited.setSelected(true);
familyMaxUsers.setValue(settings.maxFamilyClients);
setChannelType(settings.type);
syncLimits();
}
@Override
public void write(ChannelSettings settings) {
settings.phoneticName = phoneticName.getText();
settings.deleteDelay = (Integer) deleteDelay.getValue();
settings.encrypted = encrypted.isSelected();
settings.maxClientsUnlimited = maxUsersUnlimited.isSelected();
settings.maxClients = (Integer) maxUsers.getValue();
settings.familyInherited = familyInherited.isSelected();
settings.familyUnlimited = familyUnlimited.isSelected();
settings.maxFamilyClients = (Integer) familyMaxUsers.getValue();
}
private void syncLimits() {
maxUsers.setEnabled(maxUsersLimited.isSelected());
familyMaxUsers.setEnabled(familyLimited.isSelected());
}
private JPanel otherSettings() {
JPanel panel = new JPanel(new GridBagLayout());
panel.setBorder(BorderFactory.createTitledBorder("Other Settings"));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 4, 3, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
c.gridx = 0;
c.gridy = 0;
panel.add(new JLabel("Phonetic Name:"), c);
c.gridx = 1;
c.weightx = 1;
panel.add(phoneticName, c);
c.gridx = 0;
c.gridy = 1;
c.weightx = 0;
panel.add(new JLabel("Delete delay:"), c);
JPanel delay = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
delay.add(deleteDelay);
delay.add(deleteDelayMax);
c.gridx = 1;
c.weightx = 1;
panel.add(delay, c);
c.gridx = 1;
c.gridy = 2;
panel.add(encrypted, c);
return panel;
}
/**
* A limit box: its choices stacked, with the count beside the last of them —
* "Limited", the only choice it belongs to.
*/
private JPanel limitBox(String title, JRadioButton[] choices, JSpinner spinner) {
JPanel box = new JPanel();
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
box.setBorder(BorderFactory.createTitledBorder(title));
for (int i = 0; i < choices.length; i++) {
JRadioButton radio = choices[i];
radio.setAlignmentX(0f);
if (i < choices.length - 1) {
box.add(radio);
continue;
}
JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
row.setAlignmentX(0f);
row.add(radio);
row.add(spinner);
box.add(row);
}
box.add(Box.createVerticalGlue());
return box;
}
/** Keeps a section at its natural height, so the free space collects at the bottom. */
private static void fixHeight(JPanel panel) {
panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, panel.getPreferredSize().height));
}
}

View File

@@ -0,0 +1,166 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelSettings;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSlider;
import java.awt.BorderLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
/**
* The channel editor's "Audio" tab: the codec preset, or the codec and quality picked by
* hand, with the bandwidth one talking client then costs.
*/
final class ChannelAudioPanel extends JPanel implements ChannelDialog.Tab {
/** Codec names in TeamSpeak's own id order, which is what the combo box index is. */
private static final String[] CODEC_NAMES = {
"Speex Narrowband", "Speex Wideband", "Speex Ultra-Wideband",
"CELT Mono", "Opus Voice", "Opus Music"};
/**
* Bandwidth per talking client in KiB/s at quality 0 and 10, per codec, as published by
* TeamSpeak. The steps in between are interpolated: the client shows a figure per
* quality step, and only the two ends of each codec's range are documented.
*/
private static final double[][] BANDWIDTH_RANGE = {
{2.49, 5.22}, {2.69, 7.37}, {2.73, 7.57}, {6.10, 13.92}, {2.73, 7.71}, {3.08, 11.87}};
private static final int CODEC_OPUS_VOICE = 4;
private static final int CODEC_OPUS_MUSIC = 5;
private final JRadioButton voiceMobile = new JRadioButton("Voice Mobile");
private final JRadioButton voiceDesktop = new JRadioButton("Voice Desktop");
private final JRadioButton music = new JRadioButton("Music");
private final JRadioButton custom = new JRadioButton("Custom");
private final JComboBox<String> codec = new JComboBox<>(CODEC_NAMES);
private final JSlider quality = new JSlider(0, 10, 6);
private final JLabel qualityValue = new JLabel("6");
private final JLabel bandwidth = new JLabel();
private final JPanel customSettings = new JPanel(new GridBagLayout());
ChannelAudioPanel() {
super(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
ButtonGroup presets = new ButtonGroup();
presets.add(voiceMobile);
presets.add(voiceDesktop);
presets.add(music);
presets.add(custom);
voiceMobile.addActionListener(a -> applyPreset(CODEC_OPUS_VOICE, 4));
voiceDesktop.addActionListener(a -> applyPreset(CODEC_OPUS_VOICE, 6));
music.addActionListener(a -> applyPreset(CODEC_OPUS_MUSIC, 6));
custom.addActionListener(a -> setCustomEnabled(true));
codec.addActionListener(a -> updateBandwidth());
quality.setMajorTickSpacing(1);
quality.setPaintTicks(true);
quality.setSnapToTicks(true);
quality.addChangeListener(e -> updateBandwidth());
add(presetBox(), BorderLayout.WEST);
add(customBox(), BorderLayout.CENTER);
add(bandwidthRow(), BorderLayout.SOUTH);
}
@Override
public void read(ChannelSettings settings) {
codec.setSelectedIndex(Math.max(0, Math.min(CODEC_NAMES.length - 1, settings.codec)));
quality.setValue(Math.max(0, Math.min(10, settings.codecQuality)));
if (settings.codec == CODEC_OPUS_VOICE && settings.codecQuality == 4) voiceMobile.setSelected(true);
else if (settings.codec == CODEC_OPUS_VOICE && settings.codecQuality == 6) voiceDesktop.setSelected(true);
else if (settings.codec == CODEC_OPUS_MUSIC && settings.codecQuality == 6) music.setSelected(true);
else custom.setSelected(true);
setCustomEnabled(custom.isSelected());
updateBandwidth();
}
@Override
public void write(ChannelSettings settings) {
settings.codec = codec.getSelectedIndex();
settings.codecQuality = quality.getValue();
}
private void applyPreset(int codecId, int qualityValue) {
codec.setSelectedIndex(codecId);
quality.setValue(qualityValue);
setCustomEnabled(false);
updateBandwidth();
}
private void setCustomEnabled(boolean enabled) {
customSettings.setEnabled(enabled);
for (java.awt.Component child : customSettings.getComponents()) child.setEnabled(enabled);
}
private void updateBandwidth() {
qualityValue.setText(Integer.toString(quality.getValue()));
int index = Math.max(0, Math.min(BANDWIDTH_RANGE.length - 1, codec.getSelectedIndex()));
double[] range = BANDWIDTH_RANGE[index];
double value = range[0] + (range[1] - range[0]) * quality.getValue() / 10.0;
bandwidth.setText(String.format("%.2f KiB/s", value));
}
private JPanel presetBox() {
JPanel box = new JPanel();
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
box.setBorder(BorderFactory.createTitledBorder("Presets"));
for (JRadioButton radio : new JRadioButton[]{voiceMobile, voiceDesktop, music, custom}) {
radio.setAlignmentX(0f);
box.add(radio);
}
box.add(Box.createVerticalGlue());
return box;
}
private JPanel customBox() {
customSettings.setBorder(BorderFactory.createTitledBorder("Custom Settings"));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(4, 4, 4, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
c.gridx = 0;
c.gridy = 0;
customSettings.add(new JLabel("Codec:"), c);
c.gridx = 1;
c.weightx = 1;
c.gridwidth = 2;
customSettings.add(codec, c);
c.gridwidth = 1;
c.gridx = 0;
c.gridy = 1;
c.weightx = 0;
customSettings.add(new JLabel("Quality:"), c);
c.gridx = 1;
c.weightx = 1;
customSettings.add(quality, c);
c.gridx = 2;
c.weightx = 0;
customSettings.add(qualityValue, c);
c.gridx = 0;
c.gridy = 2;
c.weighty = 1;
customSettings.add(Box.createVerticalGlue(), c);
return customSettings;
}
private JPanel bandwidthRow() {
JPanel row = new JPanel(new BorderLayout(6, 0));
row.add(new JLabel("Bandwidth usage:"), BorderLayout.WEST);
row.add(bandwidth, BorderLayout.CENTER);
return row;
}
}

View File

@@ -0,0 +1,433 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.BorderFactory;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTabbedPane;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Window;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
import java.util.Map;
/**
* TeamSpeak's channel editor: the channel's name, icon, password, topic and description
* above four tabs holding everything else. The official client uses the same dialog to
* create a channel and to edit one, and so does this.
*
* <p>When editing, the channel's properties are not in the tree model — the tree only
* carries what the server pushes for display — so the dialog opens empty and fills itself
* from a {@code channelinfo}, with its own permissions arriving separately. Saving sends
* only what was actually changed, because a {@code channeledit} carrying one property the
* client may not modify is refused as a whole.
*
* <p>When creating, there is nothing to read: the dialog starts from the defaults its
* caller chose and sends a {@code channelcreate} carrying whatever deviates from a plain
* new channel, for the same reason.
*/
final class ChannelDialog extends JDialog {
/** A tab that maps a part of the channel's settings onto its controls. */
interface Tab {
void read(ChannelSettings settings);
void write(ChannelSettings settings);
}
/** How long, and how often, to keep looking for a channel icon that is still downloading. */
private static final int ICON_RETRY_MS = 500;
private static final int ICON_RETRIES = 20;
/** What the password field shows for an existing password, which is never readable. */
private static final String PASSWORD_PLACEHOLDER = "••••••••";
private static final String NAME_TOOLTIP = "<html>Name of this channel displayed in the tree."
+ "<table>"
+ "<tr><td style='white-space:nowrap'>Syntax: \"<strong>[?Spacer#]Text</strong>\"</td></tr>"
+ "<tr><td style='white-space:nowrap'>Where \"?\" stands for an alignment"
+ " (r=right, c=center, l=left),</td></tr>"
+ "<tr><td style='white-space:nowrap'>\"*\" will repeat the text to fill the whole line.</td></tr>"
+ "<tr><td style='white-space:nowrap'>Change \"#\" to get a unique channel name.</td></tr>"
+ "<tr><td style='white-space:nowrap'>[lSpacer0] a left aligned text</td></tr>"
+ "<tr><td style='white-space:nowrap'>[cSpacer1] a centered text</td></tr>"
+ "<tr><td style='white-space:nowrap'>[rSpacer2] a right aligned text</td></tr>"
+ "<tr><td style='white-space:nowrap'>Use one of the three-character-blocks as text for a"
+ " special spacer: \"---\", \"...\", \"-.-\", \"___\", \"-..\"</td></tr>"
+ "</table></html>";
private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
/** The channel being edited, or {@code null} when a new one is being created. */
private final ChannelNode channel;
/** Where a new channel goes; 0 is the top level. Unused when editing. */
private final int parentId;
private final JTextField name = new JTextField();
private final JPasswordField password = new JPasswordField();
private final JTextField topic = new JTextField();
private final JTextArea description = new JTextArea(5, 40);
private final JButton iconButton = new JButton();
private final JButton ok = new JButton("OK");
private final ChannelStandardPanel standardPanel;
private final ChannelAudioPanel audioPanel = new ChannelAudioPanel();
private final ChannelPermissionsPanel permissionsPanel = new ChannelPermissionsPanel();
private final ChannelAdvancedPanel advancedPanel = new ChannelAdvancedPanel();
/** The tabs backed by {@link ChannelSettings}; the permissions tab has its own source. */
private final List<Tab> settingsTabs;
/** The channel as the server last described it; the baseline every change is measured against. */
private ChannelSettings original;
private long iconId;
private boolean passwordEdited;
/** The editor for an existing channel. */
static ChannelDialog toEdit(Window owner, TeamspeakConnection conn, GroupIcons groupIcons,
ChannelNode channel) {
return new ChannelDialog(owner, conn, groupIcons, "Edit Channel: " + channel.name,
channel, channel.parentId, null);
}
/**
* The editor for a channel that does not exist yet.
*
* @param parentId the channel it goes below, 0 being the top level
* @param defaults what the dialog opens with, e.g. the channel type its caller prefers
*/
static ChannelDialog toCreate(Window owner, TeamspeakConnection conn, GroupIcons groupIcons,
String title, int parentId, ChannelSettings defaults) {
return new ChannelDialog(owner, conn, groupIcons, title, null, parentId, defaults);
}
private ChannelDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, String title,
ChannelNode channel, int parentId, ChannelSettings defaults) {
super(owner, title, ModalityType.APPLICATION_MODAL);
this.conn = conn;
this.groupIcons = groupIcons;
this.channel = channel;
this.parentId = parentId;
this.standardPanel = new ChannelStandardPanel(channel != null
? conn.getModel().siblingsOf(channel.id) : conn.getModel().childrenOf(parentId));
standardPanel.onTypeChanged(advancedPanel::setChannelType);
settingsTabs = List.of(standardPanel, audioPanel, advancedPanel);
JTabbedPane tabs = new JTabbedPane();
tabs.addTab("Standard", standardPanel);
tabs.addTab("Audio", audioPanel);
tabs.addTab("Permissions", permissionsPanel);
tabs.addTab("Advanced", advancedPanel);
// The tabs need no more than their natural height, so they sit at the bottom
// with the buttons and every spare pixel goes to the description instead.
JPanel bottom = new JPanel(new BorderLayout(0, 6));
bottom.add(tabs, BorderLayout.CENTER);
bottom.add(buttons(), BorderLayout.SOUTH);
getContentPane().setLayout(new BorderLayout(0, 6));
getContentPane().add(header(), BorderLayout.CENTER);
getContentPane().add(bottom, BorderLayout.SOUTH);
setEnabledForLoading(false);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(560, 620));
setMinimumSize(new Dimension(480, 520));
setLocationRelativeTo(owner);
if (channel != null) load();
else prepareNew(defaults);
}
// ---- layout ----
private JPanel header() {
JPanel panel = new JPanel(new GridBagLayout());
panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 8));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 4, 3, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
name.setToolTipText(NAME_TOOLTIP);
password.setToolTipText("Optional password for this channel.");
topic.setToolTipText("Optional topic for this channel, displayed in the info area on the right.");
password.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
passwordEdited = true;
}
@Override
public void removeUpdate(DocumentEvent e) {
passwordEdited = true;
}
@Override
public void changedUpdate(DocumentEvent e) {
passwordEdited = true;
}
});
c.gridx = 0;
c.gridy = 0;
panel.add(new JLabel("Name:"), c);
c.gridx = 1;
c.weightx = 1;
panel.add(name, c);
c.gridx = 2;
c.weightx = 0;
panel.add(iconField(), c);
c.gridx = 0;
c.gridy = 1;
panel.add(new JLabel("Password:"), c);
c.gridx = 1;
c.gridwidth = 2;
c.weightx = 1;
panel.add(password, c);
c.gridwidth = 1;
c.gridx = 0;
c.gridy = 2;
c.weightx = 0;
panel.add(new JLabel("Topic:"), c);
c.gridx = 1;
c.gridwidth = 2;
c.weightx = 1;
panel.add(topic, c);
c.gridwidth = 1;
c.gridx = 0;
c.gridy = 3;
c.weightx = 0;
panel.add(new JLabel("Description:"), c);
JButton popOut = new JButton("Edit…");
popOut.setToolTipText("Tear off description editor");
popOut.addActionListener(a -> editDescription());
c.gridx = 1;
c.gridwidth = 2;
c.fill = GridBagConstraints.NONE;
panel.add(popOut, c);
description.setLineWrap(true);
description.setWrapStyleWord(true);
c.gridx = 0;
c.gridy = 4;
c.gridwidth = 3;
c.weightx = 1;
c.weighty = 1;
c.fill = GridBagConstraints.BOTH;
panel.add(new JScrollPane(description), c);
return panel;
}
private JPanel iconField() {
iconButton.setToolTipText("Set channel icon.");
iconButton.setPreferredSize(new Dimension(26, 24));
iconButton.setMargin(new Insets(1, 1, 1, 1));
iconButton.addActionListener(a -> chooseIcon());
iconButton.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
if (e.isPopupTrigger()) showIconMenu(e);
}
@Override
public void mouseReleased(MouseEvent e) {
if (e.isPopupTrigger()) showIconMenu(e);
}
});
JPanel row = new JPanel(new BorderLayout(4, 0));
row.add(new JLabel("Icon:"), BorderLayout.WEST);
row.add(iconButton, BorderLayout.CENTER);
return row;
}
private JPanel buttons() {
JButton cancel = new JButton("Cancel");
ok.addActionListener(a -> save());
cancel.addActionListener(a -> dispose());
JPanel panel = new JPanel(new BorderLayout());
panel.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8));
JPanel right = new JPanel();
right.add(ok);
right.add(cancel);
panel.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(ok);
return panel;
}
// ---- loading ----
private void load() {
conn.requestChannelSettings(channel.id, (settings, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) {
JOptionPane.showMessageDialog(this, "Could not read the channel: " + error,
"Error", JOptionPane.ERROR_MESSAGE);
dispose();
return;
}
apply(settings);
}));
conn.requestChannelPermissions(channel.id, (permissions, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) permissionsPanel.showUnavailable("No permission to view channel permissions.");
else permissionsPanel.read(permissions);
}));
}
/**
* Opens the dialog on a channel that does not exist yet. Its permissions start empty
* and editable — they are applied to the channel once the server has created it.
*/
private void prepareNew(ChannelSettings defaults) {
permissionsPanel.read(Map.of());
apply(defaults);
name.selectAll();
}
private void apply(ChannelSettings settings) {
original = settings;
name.setText(settings.name);
topic.setText(settings.topic);
description.setText(settings.description);
description.setCaretPosition(0);
if (settings.hasPassword) password.setText(PASSWORD_PLACEHOLDER);
passwordEdited = false;
setIconId(settings.iconId);
for (Tab tab : settingsTabs) tab.read(settings);
setEnabledForLoading(true);
name.requestFocusInWindow();
}
private void setEnabledForLoading(boolean loaded) {
ok.setEnabled(loaded);
name.setEnabled(loaded);
password.setEnabled(loaded);
topic.setEnabled(loaded);
description.setEnabled(loaded);
iconButton.setEnabled(loaded);
}
// ---- icon ----
private void setIconId(long id) {
setIconId(id, ICON_RETRIES);
}
/**
* @param retries how many more times to look for an icon that is still downloading;
* one that never arrives (deleted, or not ours to read) simply stays blank
*/
private void setIconId(long id, int retries) {
iconId = id;
ImageIcon icon = groupIcons.icon(id);
iconButton.setIcon(icon);
if (icon != null || id == 0 || retries <= 0) return;
Timer retry = new Timer(ICON_RETRY_MS, null);
retry.setRepeats(false);
retry.addActionListener(e -> {
if (iconId == id) setIconId(id, retries - 1);
});
retry.start();
}
private void chooseIcon() {
IconChooserDialog chooser = new IconChooserDialog(this, conn, groupIcons);
chooser.setVisible(true);
if (chooser.isAccepted()) setIconId(chooser.getIconId());
}
private void showIconMenu(MouseEvent e) {
JPopupMenu menu = new JPopupMenu();
JMenuItem edit = new JMenuItem("Edit Icon");
edit.addActionListener(a -> chooseIcon());
JMenuItem remove = new JMenuItem("Remove Icon");
remove.setEnabled(iconId != 0);
remove.addActionListener(a -> setIconId(0));
menu.add(edit);
menu.add(remove);
menu.show(e.getComponent(), e.getX(), e.getY());
}
// ---- description ----
private void editDescription() {
DescriptionEditorDialog editor = new DescriptionEditorDialog(this, description.getText());
editor.setVisible(true);
if (editor.isAccepted()) description.setText(editor.getDescription());
}
// ---- saving ----
private void save() {
if (original == null) return;
ChannelSettings edited = original.copy();
edited.name = name.getText().trim();
edited.topic = topic.getText();
edited.description = description.getText();
edited.password = passwordEdited ? new String(password.getPassword()) : null;
edited.iconId = iconId;
for (Tab tab : settingsTabs) tab.write(edited);
if (edited.name.isEmpty()) {
JOptionPane.showMessageDialog(this, "The channel needs a name.",
"Error", JOptionPane.ERROR_MESSAGE);
return;
}
if (channel == null) {
ok.setEnabled(false);
conn.createChannel(parentId, edited.creationParameters(), permissionsPanel.changed(),
(channelId, error) -> SwingUtilities.invokeLater(
() -> finish(error, "Could not create the channel: ")));
return;
}
Map<String, String> changes = edited.changesFrom(original);
Map<String, Integer> setPermissions = permissionsPanel.changed();
List<String> clearedPermissions = permissionsPanel.cleared();
if (changes.isEmpty() && setPermissions.isEmpty() && clearedPermissions.isEmpty()) {
dispose();
return;
}
ok.setEnabled(false);
conn.applyChannelEdit(channel.id, changes, setPermissions, clearedPermissions,
error -> SwingUtilities.invokeLater(() -> finish(error, "Could not save the channel: ")));
}
/** Closes the dialog, or reports why the server would not have it and lets the user retry. */
private void finish(String error, String errorPrefix) {
if (error == null) {
dispose();
return;
}
ok.setEnabled(true);
JOptionPane.showMessageDialog(this, errorPrefix + error, "Error", JOptionPane.ERROR_MESSAGE);
}
}

View File

@@ -1,6 +1,7 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import javax.swing.JMenuItem; import javax.swing.JMenuItem;
import javax.swing.JPopupMenu; import javax.swing.JPopupMenu;
@@ -16,13 +17,63 @@ final class ChannelMenu {
static JPopupMenu build(ChannelNode channel, ServerTreePanel.Actions actions) { static JPopupMenu build(ChannelNode channel, ServerTreePanel.Actions actions) {
JPopupMenu menu = new JPopupMenu(); JPopupMenu menu = new JPopupMenu();
JMenuItem join = new JMenuItem("Join channel"); JMenuItem join = new JMenuItem("Join channel", Icons.of("CHANNEL_SWITCH"));
join.addActionListener(a -> actions.joinChannel(channel.id)); join.addActionListener(a -> actions.joinChannel(channel.id));
menu.add(join); menu.add(join);
menu.addSeparator(); menu.addSeparator();
JMenuItem files = new JMenuItem("Browse files"); // Both start out temporary: a channel made from another channel's menu is
// usually a throwaway one, and a permanent channel is a deliberate choice.
JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE"));
create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.TEMPORARY));
menu.add(create);
JMenuItem createSub = new JMenuItem("Create Sub-Channel", Icons.of("CHANNEL_CREATE_SUB"));
createSub.addActionListener(a -> actions.createChannel(channel, ChannelSettings.Type.TEMPORARY));
menu.add(createSub);
JMenuItem edit = new JMenuItem("Edit Channel", Icons.of("CHANNEL_EDIT"));
edit.addActionListener(a -> actions.editChannel(channel));
menu.add(edit);
menu.addSeparator();
addSubscriptionItems(menu, channel, actions);
JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER"));
files.addActionListener(a -> actions.browseFiles(channel)); files.addActionListener(a -> actions.browseFiles(channel));
menu.add(files); menu.add(files);
return menu; return menu;
} }
/**
* The subscription entries, offered the way the official client does: the channel
* itself toggles, and the family entries appear only where they would do something.
*/
private static void addSubscriptionItems(JPopupMenu menu, ChannelNode channel,
ServerTreePanel.Actions actions) {
if (channel.subscribed) {
add(menu, "Unsubscribe from Channel",
() -> actions.setChannelSubscribed(channel, false, false),
"UNSUBSCRIBE_FROM_CHANNEL");
} else {
add(menu, "Subscribe to Channel",
() -> actions.setChannelSubscribed(channel, false, true),
"SUBSCRIBE_TO_CHANNEL");
}
if (!channel.children.isEmpty()) {
if (!channel.allSubscribed()) {
add(menu, "Subscribe to Channel Family",
() -> actions.setChannelSubscribed(channel, true, true),
"SUBSCRIBE_TO_CHANNEL_FAMILY", "SUBSCRIBE_TO_ALL_CHANNELS");
}
if (channel.anySubscribed()) {
add(menu, "Unsubscribe from Channel Family",
() -> actions.setChannelSubscribed(channel, true, false),
"UNSUBSCRIBE_FROM_CHANNEL_FAMILY", "UNSUBSCRIBE_FROM_ALL_CHANNELS");
}
}
menu.addSeparator();
}
/** Adds an item, icon-less when the pack has none of the given keys. */
private static void add(JPopupMenu menu, String text, Runnable action, String... iconKeys) {
JMenuItem item = new JMenuItem(text, Icons.ofAny(iconKeys));
item.addActionListener(a -> action.run());
menu.add(item);
}
} }

View File

@@ -0,0 +1,148 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.SpinnerNumberModel;
import java.awt.Color;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* The channel editor's "Permissions" tab: the needed-power permissions set on the channel
* itself, which is the subset of channel permissions TeamSpeak surfaces here.
*
* <p>These are not channel properties but permissions, so they are read with
* {@code channelpermlist} and written with {@code channeladdperm}. A power left at zero is
* not a permission the channel carries: clearing a spinner back to zero removes the
* permission again so the channel inherits it, which is how the official client behaves.
*/
final class ChannelPermissionsPanel extends JPanel {
/** The regular powers, in the order the official dialog lists them. */
private static final String[][] REGULAR = {
{"i_channel_needed_join_power", "Join:"},
{"i_channel_needed_subscribe_power", "Subscribe:"},
{"i_channel_needed_description_view_power", "Desc. View:"},
{"i_channel_needed_modify_power", "Modify:"},
{"i_channel_needed_delete_power", "Delete:"}};
private static final String[][] FILE_TRANSFER = {
{"i_ft_needed_file_browse_power", "Browse:"},
{"i_ft_needed_file_upload_power", "Upload:"},
{"i_ft_needed_file_download_power", "Download:"},
{"i_ft_needed_file_rename_power", "Rename:"},
{"i_ft_needed_directory_create_power", "Dir. Create:"}};
private final Map<String, JSpinner> spinners = new LinkedHashMap<>();
/** The values the server reported, to tell an edited power from an untouched one. */
private final Map<String, Integer> original = new LinkedHashMap<>();
private final JLabel status = new JLabel(" ");
ChannelPermissionsPanel() {
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
JPanel groups = new JPanel();
groups.setLayout(new BoxLayout(groups, BoxLayout.X_AXIS));
groups.add(group("Regular Needed Powers", REGULAR));
groups.add(Box.createHorizontalStrut(8));
groups.add(group("File Transfer Needed Powers", FILE_TRANSFER));
groups.setAlignmentX(0f);
add(groups);
status.setAlignmentX(0f);
status.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2));
add(status);
add(Box.createVerticalGlue());
setEditable(false);
}
/** Fills the spinners from a {@code channelpermlist} result and enables editing. */
void read(Map<String, Integer> permissions) {
original.clear();
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
int value = permissions.getOrDefault(entry.getKey(), 0);
original.put(entry.getKey(), value);
entry.getValue().setValue(value);
}
setEditable(true);
setStatus(" ", false);
}
/** Greys the tab out with a reason, e.g. when the server refuses to show the permissions. */
void showUnavailable(String message) {
setEditable(false);
setStatus(message, true);
}
/** The permissions whose power was changed to a non-zero value. */
Map<String, Integer> changed() {
Map<String, Integer> changed = new LinkedHashMap<>();
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
int value = (Integer) entry.getValue().getValue();
if (value != 0 && value != original.getOrDefault(entry.getKey(), 0)) {
changed.put(entry.getKey(), value);
}
}
return changed;
}
/** The permissions cleared back to zero, which are removed from the channel. */
List<String> cleared() {
List<String> cleared = new ArrayList<>();
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
int value = (Integer) entry.getValue().getValue();
if (value == 0 && original.getOrDefault(entry.getKey(), 0) != 0) cleared.add(entry.getKey());
}
return cleared;
}
void setStatus(String message, boolean error) {
status.setText(message == null || message.isEmpty() ? " " : message);
status.setForeground(error ? Color.RED.darker() : Theme.chatSystem());
}
private void setEditable(boolean editable) {
for (JSpinner spinner : spinners.values()) spinner.setEnabled(editable);
}
private JPanel group(String title, String[][] permissions) {
JPanel panel = new JPanel(new GridBagLayout());
panel.setBorder(BorderFactory.createTitledBorder(title));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 4, 3, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
int row = 0;
for (String[] permission : permissions) {
JSpinner spinner = Spinners.compact(new JSpinner(new SpinnerNumberModel(0, 0, 9999, 1)));
spinners.put(permission[0], spinner);
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
panel.add(new JLabel(permission[1]), c);
c.gridx = 1;
c.weightx = 1;
c.fill = GridBagConstraints.NONE;
panel.add(spinner, c);
c.fill = GridBagConstraints.HORIZONTAL;
row++;
}
// Keeps the rows at the top of the group instead of centred in whatever height
// the tab happens to give it.
c.gridx = 0;
c.gridy = row;
c.weighty = 1;
panel.add(Box.createGlue(), c);
return panel;
}
}

View File

@@ -0,0 +1,195 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.DefaultComboBoxModel;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSeparator;
import javax.swing.JSpinner;
import javax.swing.SpinnerNumberModel;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.util.List;
import java.util.function.Consumer;
/**
* The channel editor's "Standard" tab: how long the channel lives, whether it is the
* server's default one, where it sorts among its siblings, and its moderation setting.
*/
final class ChannelStandardPanel extends JPanel implements ChannelDialog.Tab {
private final JRadioButton temporary = new JRadioButton("Temporary");
private final JRadioButton semiPermanent = new JRadioButton("Semi-Permanent");
private final JRadioButton permanent = new JRadioButton("Permanent");
private final JCheckBox defaultChannel = new JCheckBox("Default Channel");
private final JComboBox<SortEntry> sortAfter = new JComboBox<>();
private final JSpinner talkPower = new JSpinner(new SpinnerNumberModel(0, 0, 9999, 1));
private Consumer<ChannelSettings.Type> typeListener;
/**
* @param siblings the channels this one shares a parent with, in tree order and
* excluding the channel being edited
*/
ChannelStandardPanel(List<ChannelNode> siblings) {
super(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
temporary.setToolTipText("Channel will be deleted when the last user left.");
semiPermanent.setToolTipText("Channel exists until server is restarted.");
permanent.setToolTipText("Channel will exist until manually deleted.");
ButtonGroup types = new ButtonGroup();
types.add(temporary);
types.add(semiPermanent);
types.add(permanent);
for (JRadioButton radio : new JRadioButton[]{temporary, semiPermanent, permanent}) {
radio.addActionListener(a -> fireTypeChanged());
}
defaultChannel.setToolTipText("<html>The default channel is the place where new clients join on login."
+ "<br>There can be only one default channel for the whole server.</html>");
DefaultComboBoxModel<SortEntry> order = new DefaultComboBoxModel<>();
order.addElement(new SortEntry(0, "(first)"));
for (ChannelNode sibling : siblings) order.addElement(new SortEntry(sibling.id, sibling.name));
sortAfter.setModel(order);
sortAfter.setToolTipText("Channel will be sorted below this channel.");
sortAfter.setPreferredSize(new Dimension(200, sortAfter.getPreferredSize().height));
Spinners.compact(talkPower);
add(typeBox(), BorderLayout.WEST);
add(rightColumn(), BorderLayout.CENTER);
}
/** Notified whenever the channel type changes, so the delete delay can follow it. */
void onTypeChanged(Consumer<ChannelSettings.Type> listener) {
this.typeListener = listener;
}
@Override
public void read(ChannelSettings settings) {
switch (settings.type) {
case TEMPORARY:
temporary.setSelected(true);
break;
case SEMI_PERMANENT:
semiPermanent.setSelected(true);
break;
default:
permanent.setSelected(true);
break;
}
defaultChannel.setSelected(settings.defaultChannel);
// The default channel cannot simply stop being one; another has to take over.
defaultChannel.setEnabled(!settings.defaultChannel);
select(settings.order);
talkPower.setValue(settings.neededTalkPower);
fireTypeChanged();
}
@Override
public void write(ChannelSettings settings) {
settings.type = selectedType();
settings.defaultChannel = defaultChannel.isSelected();
SortEntry entry = (SortEntry) sortAfter.getSelectedItem();
settings.order = entry == null ? 0 : entry.channelId;
settings.neededTalkPower = (Integer) talkPower.getValue();
}
private ChannelSettings.Type selectedType() {
if (temporary.isSelected()) return ChannelSettings.Type.TEMPORARY;
if (semiPermanent.isSelected()) return ChannelSettings.Type.SEMI_PERMANENT;
return ChannelSettings.Type.PERMANENT;
}
private void fireTypeChanged() {
if (typeListener != null) typeListener.accept(selectedType());
}
private void select(int channelId) {
for (int i = 0; i < sortAfter.getItemCount(); i++) {
if (sortAfter.getItemAt(i).channelId == channelId) {
sortAfter.setSelectedIndex(i);
return;
}
}
sortAfter.setSelectedIndex(0);
}
private JPanel typeBox() {
JPanel box = new JPanel();
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
box.setBorder(BorderFactory.createTitledBorder("Channel Type"));
for (JRadioButton radio : new JRadioButton[]{temporary, semiPermanent, permanent}) {
radio.setAlignmentX(0f);
box.add(radio);
}
JSeparator line = new JSeparator();
line.setAlignmentX(0f);
line.setMaximumSize(new Dimension(Integer.MAX_VALUE, 8));
box.add(Box.createVerticalStrut(4));
box.add(line);
box.add(Box.createVerticalStrut(4));
defaultChannel.setAlignmentX(0f);
box.add(defaultChannel);
box.add(Box.createVerticalGlue());
return box;
}
private JPanel rightColumn() {
JPanel sort = new JPanel(new BorderLayout());
sort.setBorder(BorderFactory.createTitledBorder("Sort This Channel After:"));
sort.add(sortAfter, BorderLayout.CENTER);
JPanel moderation = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
moderation.setBorder(BorderFactory.createTitledBorder("Moderation"));
JLabel label = new JLabel("Needed Talk Power:");
label.setToolTipText("Talk Power required to speak in this channel.");
moderation.add(label);
moderation.add(talkPower);
// Both groups keep their natural height; the filler below soaks up the rest, so
// they sit at the top instead of stretching over the whole tab.
JPanel column = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.gridx = 0;
c.fill = GridBagConstraints.HORIZONTAL;
c.weightx = 1;
c.insets = new Insets(0, 0, 6, 0);
column.add(sort, c);
column.add(moderation, c);
c.weighty = 1;
c.fill = GridBagConstraints.BOTH;
column.add(Box.createGlue(), c);
return column;
}
/** One entry of the "sort after" list: a sibling channel, or the top of the list. */
private static final class SortEntry {
final int channelId;
final String label;
SortEntry(int channelId, String label) {
this.channelId = channelId;
this.label = label;
}
@Override
public String toString() {
return label;
}
}
}

View File

@@ -4,6 +4,7 @@ import com.ts3client.text.BBCode;
import com.ts3client.text.TsLink; import com.ts3client.text.TsLink;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.Icon;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JEditorPane; import javax.swing.JEditorPane;
import javax.swing.JLabel; import javax.swing.JLabel;
@@ -15,8 +16,6 @@ import javax.swing.SwingUtilities;
import javax.swing.event.HyperlinkEvent; import javax.swing.event.HyperlinkEvent;
import javax.swing.text.BadLocationException; import javax.swing.text.BadLocationException;
import javax.swing.text.html.HTMLDocument; import javax.swing.text.html.HTMLDocument;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Component; import java.awt.Component;
import java.awt.Cursor; import java.awt.Cursor;
@@ -25,6 +24,7 @@ import java.awt.FlowLayout;
import java.awt.Point; import java.awt.Point;
import java.awt.event.MouseAdapter; import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent; import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.io.IOException; import java.io.IOException;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
@@ -62,6 +62,10 @@ public final class ChatPanel extends JPanel {
private final Tab channelTab = new Tab(Target.CHANNEL, 0, "Channel"); private final Tab channelTab = new Tab(Target.CHANNEL, 0, "Channel");
/** Private chats keyed by the peer's client id. */ /** Private chats keyed by the peer's client id. */
private final Map<Integer, Tab> privateTabs = new LinkedHashMap<>(); private final Map<Integer, Tab> privateTabs = new LinkedHashMap<>();
/** Static-content tabs (e.g. a moved-out description), keyed by caller-chosen id. */
private final Map<String, Tab> noteTabs = new LinkedHashMap<>();
private final TabDragReorder dragReorder = new TabDragReorder(tabs, this::moveTab);
private SendHandler sendHandler; private SendHandler sendHandler;
private LinkHandler linkHandler; private LinkHandler linkHandler;
@@ -69,7 +73,8 @@ public final class ChatPanel extends JPanel {
public ChatPanel() { public ChatPanel() {
super(new BorderLayout()); super(new BorderLayout());
tabs.setFont(Theme.UI_FONT); tabs.setFont(Theme.uiFont());
tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
addTab(serverTab, false); addTab(serverTab, false);
addTab(channelTab, false); addTab(channelTab, false);
tabs.setSelectedIndex(0); tabs.setSelectedIndex(0);
@@ -77,6 +82,8 @@ public final class ChatPanel extends JPanel {
Tab t = selectedTab(); Tab t = selectedTab();
if (t != null) t.setUnread(false); if (t != null) t.setUnread(false);
}); });
tabs.addMouseWheelListener(this::onWheel);
dragReorder.attach(tabs);
add(tabs, BorderLayout.CENTER); add(tabs, BorderLayout.CENTER);
JPanel bottom = new JPanel(new BorderLayout(4, 0)); JPanel bottom = new JPanel(new BorderLayout(4, 0));
@@ -130,6 +137,40 @@ public final class ChatPanel extends JPanel {
}); });
} }
/** Drops every static-content tab (moved-out descriptions), e.g. after disconnecting. */
public void closeNoteTabs() {
edt(() -> {
for (Tab t : noteTabs.values()) tabs.remove(t.scroll);
noteTabs.clear();
});
}
/** Closes a single static-content tab by its key, if open; does not run its {@code onClose}. */
public void closeNoteTab(String key) {
edt(() -> {
Tab tab = noteTabs.remove(key);
if (tab != null) tabs.remove(tab.scroll);
});
}
/**
* Opens (or replaces the content of, and focuses) a static-content tab, e.g. a
* channel/client description moved out of the info panel. {@code onClose} runs
* when the tab is closed, so the caller can un-hide the description again.
*/
public void openDescriptionTab(String key, Icon icon, String title, String htmlBody, Runnable onClose) {
edt(() -> {
Tab tab = noteTabs.get(key);
if (tab == null) {
tab = new Tab(key, icon, title, onClose);
noteTabs.put(key, tab);
addTab(tab, true);
}
tab.setStaticContent(htmlBody);
tabs.setSelectedComponent(tab.scroll);
});
}
private void fireSend() { private void fireSend() {
String text = input.getText().trim(); String text = input.getText().trim();
if (text.isEmpty() || sendHandler == null) return; if (text.isEmpty() || sendHandler == null) return;
@@ -143,10 +184,20 @@ public final class ChatPanel extends JPanel {
/** System notices always land in the server tab. */ /** System notices always land in the server tab. */
public void appendSystem(String text) { public void appendSystem(String text) {
edt(() -> serverTab.appendLine("<span style=\"color:#8A8A8A\">" + stamp() edt(() -> serverTab.appendLine("<span class=\"muted\">" + stamp()
+ BBCode.escape(text) + "</span>")); + BBCode.escape(text) + "</span>"));
} }
/**
* Server-side events (client joins/leaves/moves, group changes, channel edits, …).
* These lines name clients and channels with TeamSpeak's own links, which stay
* clickable here — everything else in them is literal text.
*/
public void appendServerLog(String text) {
edt(() -> serverTab.appendLine("<span class=\"event\">" + stamp()
+ BBCode.linksToHtml(text) + "</span>"));
}
public void appendServerMessage(int fromId, String from, String text) { public void appendServerMessage(int fromId, String from, String text) {
edt(() -> serverTab.appendMessage(fromId, from, text)); edt(() -> serverTab.appendMessage(fromId, from, text));
} }
@@ -180,10 +231,40 @@ public final class ChatPanel extends JPanel {
} }
private void closeTab(Tab tab) { private void closeTab(Tab tab) {
privateTabs.remove(tab.clientId); if (tab.noteKey != null) {
noteTabs.remove(tab.noteKey);
if (tab.onClose != null) tab.onClose.run();
} else {
privateTabs.remove(tab.clientId);
}
tabs.remove(tab.scroll); tabs.remove(tab.scroll);
} }
/** Drags a tab from one position to another, keeping the current selection on screen. */
private void moveTab(int from, int to) {
if (from < 0 || to < 0 || from >= tabs.getTabCount() || to >= tabs.getTabCount() || from == to) return;
Component content = tabs.getComponentAt(from);
String title = tabs.getTitleAt(from);
Icon icon = tabs.getIconAt(from);
String tip = tabs.getToolTipTextAt(from);
Component header = tabs.getTabComponentAt(from);
boolean wasSelected = tabs.getSelectedIndex() == from;
tabs.removeTabAt(from);
tabs.insertTab(title, icon, content, tip, to);
tabs.setTabComponentAt(to, header);
if (wasSelected) tabs.setSelectedIndex(to);
}
private void onWheel(MouseWheelEvent e) {
Component content = tabs.getSelectedComponent();
if (content != null && content.getBounds().contains(e.getPoint())) return;
int steps = e.getWheelRotation();
if (steps == 0) return;
int target = Math.max(0, Math.min(tabs.getTabCount() - 1, tabs.getSelectedIndex() + steps));
if (target != tabs.getSelectedIndex()) tabs.setSelectedIndex(target);
e.consume();
}
private Tab selectedTab() { private Tab selectedTab() {
Component c = tabs.getSelectedComponent(); Component c = tabs.getSelectedComponent();
if (c == serverTab.scroll) return serverTab; if (c == serverTab.scroll) return serverTab;
@@ -205,40 +286,39 @@ public final class ChatPanel extends JPanel {
final Target target; final Target target;
final int clientId; final int clientId;
final String title; final String title;
final JEditorPane log = new JEditorPane(); final JEditorPane log = HtmlStyles.pane("font-family:sans-serif; font-size:11px; margin:4px 6px;");
final JScrollPane scroll; final JScrollPane scroll;
private final HTMLDocument doc; /** Set for a static-content tab (e.g. a moved-out description); null for a conversation. */
final String noteKey;
final Runnable onClose;
private final Icon icon;
private JLabel titleLabel; private JLabel titleLabel;
Tab(Target target, int clientId, String title) { Tab(Target target, int clientId, String title) {
this(target, clientId, title, null, null, null);
}
/** A static-content tab: no send target, closing it runs {@code onClose}. */
Tab(String noteKey, Icon icon, String title, Runnable onClose) {
this(null, 0, title, icon, noteKey, onClose);
}
private Tab(Target target, int clientId, String title, Icon icon, String noteKey, Runnable onClose) {
this.target = target; this.target = target;
this.clientId = clientId; this.clientId = clientId;
this.title = title; this.title = title;
this.icon = icon;
this.noteKey = noteKey;
this.onClose = onClose;
HTMLEditorKit kit = new HTMLEditorKit();
StyleSheet css = new StyleSheet();
css.addStyleSheet(kit.getStyleSheet());
css.addRule("body { font-family:sans-serif; font-size:11px; color:#202020; margin:4px 6px; }");
css.addRule("a { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }");
// Client references look exactly like a message author's name.
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
+ " { color:" + hex(Theme.CHAT_NAME) + "; font-weight:bold; text-decoration:none; }");
// External links are underlined so they can't be mistaken for an identity.
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
+ " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }");
kit.setStyleSheet(css);
log.setEditorKit(kit);
log.setEditable(false);
log.setBackground(Theme.CHAT_BG);
log.setText("<html><body><div id=\"chatlog\"></div></body></html>"); log.setText("<html><body><div id=\"chatlog\"></div></body></html>");
doc = (HTMLDocument) log.getDocument();
log.addHyperlinkListener(e -> { log.addHyperlinkListener(e -> {
if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return; if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return;
onLink(e.getDescription()); onLink(e.getDescription());
}); });
scroll = new JScrollPane(log); scroll = new JScrollPane(log);
HtmlStyles.fillViewport(log);
scroll.setBorder(BorderFactory.createEmptyBorder()); scroll.setBorder(BorderFactory.createEmptyBorder());
} }
@@ -259,13 +339,15 @@ public final class ChatPanel extends JPanel {
Component header(boolean closable) { Component header(boolean closable) {
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
p.setOpaque(false); p.setOpaque(false);
titleLabel = new JLabel(title); titleLabel = new JLabel(title, icon, JLabel.LEADING);
titleLabel.setFont(Theme.UI_FONT); titleLabel.setFont(Theme.uiFont());
p.add(titleLabel); p.add(titleLabel);
dragReorder.attach(p);
dragReorder.attach(titleLabel);
if (closable) { if (closable) {
JLabel close = new JLabel("×"); JLabel close = new JLabel("×");
close.setFont(Theme.UI_BOLD); close.setFont(Theme.uiBold());
close.setForeground(Theme.CHAT_SYSTEM); close.setForeground(Theme.chatSystem());
close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
close.setToolTipText("Close chat"); close.setToolTipText("Close chat");
close.addMouseListener(new MouseAdapter() { close.addMouseListener(new MouseAdapter() {
@@ -289,8 +371,8 @@ public final class ChatPanel extends JPanel {
void setUnread(boolean unread) { void setUnread(boolean unread) {
if (titleLabel == null) return; if (titleLabel == null) return;
titleLabel.setFont(unread ? Theme.UI_BOLD : Theme.UI_FONT); titleLabel.setFont(unread ? Theme.uiBold() : Theme.uiFont());
titleLabel.setForeground(unread ? Theme.ACCENT : null); titleLabel.setForeground(unread ? Theme.accent() : null);
} }
void appendMessage(int fromId, String from, String text) { void appendMessage(int fromId, String from, String text) {
@@ -298,13 +380,20 @@ public final class ChatPanel extends JPanel {
String sender = fromId > 0 String sender = fromId > 0
? "<a class=\"" + BBCode.IDENTITY_LINK_CLASS + "\" href=\"" ? "<a class=\"" + BBCode.IDENTITY_LINK_CLASS + "\" href=\""
+ BBCode.escape(TsLink.clientHref(fromId, "", from)) + "\">" + name + "</a>" + BBCode.escape(TsLink.clientHref(fromId, "", from)) + "\">" + name + "</a>"
: "<b style=\"color:" + hex(Theme.CHAT_NAME) + "\">" + name + "</b>"; : "<span class=\"name\">" + name + "</span>";
appendLine("<span style=\"color:#8A8A8A\">" + stamp() + "</span>" appendLine("<span class=\"muted\">" + stamp() + "</span>"
+ sender + ": " + BBCode.toHtml(text)); + sender + ": " + BBCode.toHtml(text));
} }
/** Replaces the whole log with fixed content; used by static-content tabs. */
void setStaticContent(String html) {
log.setText("<html><body><div id=\"chatlog\">" + html + "</div></body></html>");
log.setCaretPosition(0);
}
void appendLine(String html) { void appendLine(String html) {
try { try {
HTMLDocument doc = (HTMLDocument) log.getDocument();
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>"); doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
log.setCaretPosition(doc.getLength()); log.setCaretPosition(doc.getLength());
} catch (BadLocationException | IOException ignored) { } catch (BadLocationException | IOException ignored) {
@@ -312,8 +401,4 @@ public final class ChatPanel extends JPanel {
if (tabs.getSelectedComponent() != scroll) setUnread(true); if (tabs.getSelectedComponent() != scroll) setUnread(true);
} }
} }
private static String hex(java.awt.Color c) {
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
}
} }

View File

@@ -1,9 +1,15 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
import com.ts3client.net.ServerModel;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JMenu;
import javax.swing.JMenuItem; import javax.swing.JMenuItem;
import javax.swing.JPopupMenu; import javax.swing.JPopupMenu;
import java.util.List;
/** /**
* The context menu for a client, shared by the server tree and the clickable * The context menu for a client, shared by the server tree and the clickable
@@ -14,29 +20,153 @@ final class ClientMenu {
private ClientMenu() { private ClientMenu() {
} }
static JPopupMenu build(ClientEntry client, boolean self, ServerTreePanel.Actions actions) { /**
* @param fromTree whether this menu was opened from the server tree itself, as
* opposed to a client link elsewhere (chat log, etc.) — "Find
* Client in Channel Tree" is redundant in the former case.
*/
static JPopupMenu build(ClientEntry client, boolean self, boolean fromTree, ServerModel model,
GroupIcons groupIcons, ServerTreePanel.Actions actions) {
JPopupMenu menu = new JPopupMenu(); JPopupMenu menu = new JPopupMenu();
if (!self) { if (!self) {
JMenuItem pm = new JMenuItem("Open text chat"); JMenuItem pm = new JMenuItem("Open text chat", Icons.of("PLAYER_CHAT"));
pm.addActionListener(a -> actions.openPrivateChat(client)); pm.addActionListener(a -> actions.openPrivateChat(client));
menu.add(pm); menu.add(pm);
JMenuItem poke = new JMenuItem("Poke"); JMenuItem poke = new JMenuItem("Poke", Icons.of("POKE"));
poke.addActionListener(a -> actions.pokeClient(client)); poke.addActionListener(a -> actions.pokeClient(client));
menu.add(poke); menu.add(poke);
menu.addSeparator(); menu.addSeparator();
JMenuItem kickChannel = new JMenuItem("Kick Client from Channel", Icons.of("KICK_FROM_CHANNEL"));
kickChannel.addActionListener(a -> actions.kickClientFromChannel(client));
menu.add(kickChannel);
JMenuItem kickServer = new JMenuItem("Kick Client from Server", Icons.of("KICK_FROM_SERVER"));
kickServer.addActionListener(a -> actions.kickClientFromServer(client));
menu.add(kickServer);
JMenuItem ban = new JMenuItem("Ban Client", Icons.of("BAN_CLIENT"));
ban.addActionListener(a -> actions.banClient(client));
menu.add(ban);
boolean muted = actions.isClientLocallyMuted(client.id); boolean muted = actions.isClientLocallyMuted(client.id);
JMenuItem mute = new JMenuItem(muted ? "Unmute client" : "Mute client"); JMenuItem mute = new JMenuItem(muted ? "Unmute client" : "Mute client",
Icons.of(muted ? "PLAYER_ON" : "INPUT_MUTED"));
mute.addActionListener(a -> actions.toggleClientMute(client)); mute.addActionListener(a -> actions.toggleClientMute(client));
menu.add(mute); menu.add(mute);
} else { } else {
JMenuItem me = new JMenuItem("This is you"); JMenuItem me = new JMenuItem("This is you", Icons.of("PLAYER_OFF"));
me.setEnabled(false); me.setEnabled(false);
menu.add(me); menu.add(me);
} }
menu.addSeparator(); menu.addSeparator();
JMenuItem info = new JMenuItem("Connection Info"); menu.add(buildServerGroupMenu(client, model, groupIcons, actions));
menu.add(buildChannelGroupMenu(client, model, groupIcons, actions));
menu.addSeparator();
if (!fromTree) {
JMenuItem findInTree = new JMenuItem("Find Client in Channel Tree", Icons.of("CHANNEL_SWITCH"));
findInTree.setEnabled(isVisible(client, model));
findInTree.addActionListener(a -> actions.findClientInTree(client));
menu.add(findInTree);
}
JMenuItem description = new JMenuItem("Change Description", Icons.of("EDIT"));
description.addActionListener(a -> actions.changeClientDescription(client));
menu.add(description);
JMenuItem info = new JMenuItem("Connection Info", Icons.of("INFO"));
info.addActionListener(a -> actions.showConnectionInfo(client)); info.addActionListener(a -> actions.showConnectionInfo(client));
menu.add(info); menu.add(info);
if (!self) {
menu.addSeparator();
JMenuItem joinChannel = new JMenuItem("Join Channel of Client", Icons.of("CHANNEL_SWITCH"));
joinChannel.setEnabled(canJoinChannelOf(client, model));
joinChannel.addActionListener(a -> actions.joinChannel(client.channelId));
menu.add(joinChannel);
JMenuItem moveHere = new JMenuItem("Move Client to own Channel", Icons.of("MOVE_CLIENT_TO_OWN_CHANNEL"));
moveHere.addActionListener(a -> actions.moveClientToOwnChannel(client));
menu.add(moveHere);
}
return menu; return menu;
} }
/**
* Whether the client's channel is actually rendered in the tree — it won't be if
* we're not subscribed to it (e.g. it needs more subscribe power than we have).
*/
private static boolean isVisible(ClientEntry client, ServerModel model) {
ChannelNode channel = model.getChannel(client.channelId);
return channel != null && channel.subscribed;
}
/**
* Whether we can expect a join to succeed: the channel must be visible to us, and
* not already full. This can't account for a join-power permission requirement —
* unlike group membership, TS3 doesn't expose a channel's join-power threshold as
* a plain property, only as a permission resolved through channel/channel-group/
* server-group inheritance, which isn't something a regular client can query.
*/
private static boolean canJoinChannelOf(ClientEntry client, ServerModel model) {
ChannelNode channel = model.getChannel(client.channelId);
if (channel == null || !channel.subscribed) return false;
return channel.maxClients < 0 || channel.clients.size() < channel.maxClients;
}
private static JMenu buildServerGroupMenu(ClientEntry client, ServerModel model, GroupIcons groupIcons,
ServerTreePanel.Actions actions) {
JMenu menu = new JMenu("Set Server Groups");
menu.setIcon(Icons.of("PERMISSIONS_SERVER_GROUPS"));
JMenuItem dialog = new JMenuItem("Server Groups Dialog...");
dialog.addActionListener(a -> actions.showServerGroupsDialog(client));
menu.add(dialog);
menu.addSeparator();
int addPower = model.selfPermissionValue("i_group_needed_member_add_power");
int removePower = model.selfPermissionValue("i_group_needed_member_remove_power");
for (Group g : model.allServerGroups()) {
boolean assigned = contains(client.serverGroupIds, g.id);
if (!canAssign(g, assigned, addPower, removePower)) continue;
JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g));
item.setSelected(assigned);
item.addActionListener(a -> actions.setClientServerGroup(client, g, !assigned));
menu.add(item);
}
return menu;
}
private static JMenu buildChannelGroupMenu(ClientEntry client, ServerModel model, GroupIcons groupIcons,
ServerTreePanel.Actions actions) {
JMenu menu = new JMenu("Set Channel Group");
menu.setIcon(Icons.of("PERMISSIONS_CHANNEL_GROUPS"));
int addPower = model.selfPermissionValue("i_group_needed_member_add_power");
int removePower = model.selfPermissionValue("i_group_needed_member_remove_power");
int defaultGroupId = model.defaultChannelGroupId();
for (Group g : model.allChannelGroups()) {
if (g.id == defaultGroupId) continue;
boolean assigned = client.channelGroupId == g.id;
if (!canAssign(g, assigned, addPower, removePower)) continue;
JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g));
item.setSelected(assigned);
item.addActionListener(a -> actions.setClientChannelGroup(client, g));
menu.add(item);
}
return menu;
}
/**
* Whether the local client has enough power to (un)assign this group: the add
* power always gates the checkbox, and the remove power additionally gates
* unassigning an already-held group. {@code -1} is TS3's "unlimited" sentinel on
* either side: an unlimited local power always passes, and a group that needs
* unlimited power can only be touched by a local client that has it.
*/
static boolean canAssign(Group g, boolean assigned, int addPower, int removePower) {
return hasPower(g.neededMemberAddPower, addPower)
&& (!assigned || hasPower(g.neededMemberRemovePower, removePower));
}
private static boolean hasPower(int needed, int own) {
if (own == -1) return true;
if (needed == -1) return false;
return own >= needed;
}
private static boolean contains(int[] ids, int id) {
for (int i : ids) if (i == id) return true;
return false;
}
} }

View File

@@ -0,0 +1,190 @@
package com.ts3client.ui;
import com.ts3client.config.ClientVersion;
import com.ts3client.config.Settings;
import javax.swing.BorderFactory;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JTextField;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
/**
* Options page for the version and operating system reported to the server, which
* is what other clients see next to the nickname ("3.6.2 on Windows").
*
* <p>Servers verify TeamSpeak's signature over the version and platform and refuse
* the connection ("client is modified") when it does not match, so the page is
* built around the signed versions in {@link ClientVersion#PRESETS}. The fields
* behind them can still be edited by hand for a version whose signature is known
* from somewhere else. Changes take effect on the next connect.
*/
final class ClientVersionPanel extends JPanel {
/** Combo entry that leaves the three fields to the user. */
private static final String CUSTOM = "Custom…";
private final Settings settings;
private final JCheckBox enabled = new JCheckBox("Report a custom version and operating system");
private final JComboBox<Object> presetCombo = new JComboBox<>();
private final JTextField versionField = new JTextField(24);
private final JComboBox<String> platformCombo = new JComboBox<>();
private final JTextField signField = new JTextField(24);
/** Set while a preset is being copied into the fields, so that does not count as an edit. */
private boolean filling;
ClientVersionPanel(Settings settings) {
super(new BorderLayout(0, 8));
this.settings = settings;
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
add(buildForm(), BorderLayout.NORTH);
load();
enabled.addActionListener(e -> updateEnabled());
updateEnabled();
}
/** Copies the edited version into the settings; the caller saves them. */
void apply() {
settings.customVersion = enabled.isSelected();
settings.clientVersion = versionField.getText().trim();
settings.clientPlatform = String.valueOf(platformCombo.getEditor().getItem()).trim();
settings.clientVersionSign = signField.getText().trim();
}
private JComponent buildForm() {
JPanel p = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(2, 2, 2, 2);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
presetCombo.addItem(CUSTOM);
for (ClientVersion preset : ClientVersion.PRESETS) presetCombo.addItem(preset);
presetCombo.setRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focus) {
super.getListCellRendererComponent(list, value, index, selected, focus);
setText(value instanceof ClientVersion v ? v.label() : String.valueOf(value));
return this;
}
});
presetCombo.setToolTipText("Versions with a signature TeamSpeak issued for them");
presetCombo.addActionListener(e -> onPresetSelected());
platformCombo.setEditable(true);
for (String platform : ClientVersion.PLATFORMS) platformCombo.addItem(platform);
versionField.setToolTipText("Version string, e.g. 3.6.2 [Build: 1695203293]");
platformCombo.setToolTipText("Operating system shown to other clients");
signField.setToolTipText("TeamSpeak's signature for this version and platform");
versionField.getDocument().addDocumentListener(new EditListener());
signField.getDocument().addDocumentListener(new EditListener());
platformCombo.addActionListener(e -> onFieldEdited());
int row = 0;
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(enabled, c);
c.gridwidth = 1;
addRow(p, c, row++, new JLabel("Version:"), presetCombo);
addRow(p, c, row++, new JLabel("Version string:"), versionField);
addRow(p, c, row++, new JLabel("Operating system:"), platformCombo);
addRow(p, c, row++, new JLabel("Signature:"), signField);
c.gridx = 0;
c.gridy = row;
c.gridwidth = 2;
p.add(new JLabel("<html><body style='width:320px'>The three fields belong together: the "
+ "signature is TeamSpeak's, over that version and operating system. A server "
+ "answers \"client is modified\" and refuses the connection when it does not "
+ "accept the version — made up, or older than the server's minimum — so prefer "
+ "a recent one from the list. Takes effect the next time you connect."
+ "</body></html>"), c);
return p;
}
private void load() {
enabled.setSelected(settings.customVersion);
ClientVersion preset = ClientVersion.match(
settings.clientVersion, settings.clientPlatform, settings.clientVersionSign);
fill(preset != null ? preset
: new ClientVersion("", settings.clientVersion, settings.clientPlatform, settings.clientVersionSign));
presetCombo.setSelectedItem(preset != null ? preset : CUSTOM);
}
private void onPresetSelected() {
if (presetCombo.getSelectedItem() instanceof ClientVersion preset) fill(preset);
}
/** A hand-edited field no longer describes the selected preset. */
private void onFieldEdited() {
if (filling) return;
ClientVersion preset = ClientVersion.match(versionField.getText().trim(),
String.valueOf(platformCombo.getEditor().getItem()).trim(), signField.getText().trim());
presetCombo.setSelectedItem(preset != null ? preset : CUSTOM);
}
private void fill(ClientVersion version) {
filling = true;
try {
versionField.setText(version.version());
platformCombo.setSelectedItem(version.platform());
platformCombo.getEditor().setItem(version.platform());
signField.setText(version.sign());
} finally {
filling = false;
}
}
private void updateEnabled() {
boolean on = enabled.isSelected();
presetCombo.setEnabled(on);
versionField.setEnabled(on);
platformCombo.setEnabled(on);
signField.setEnabled(on);
}
/** Notices any typing in the version or signature field. */
private final class EditListener implements javax.swing.event.DocumentListener {
@Override
public void insertUpdate(javax.swing.event.DocumentEvent e) {
onFieldEdited();
}
@Override
public void removeUpdate(javax.swing.event.DocumentEvent e) {
onFieldEdited();
}
@Override
public void changedUpdate(javax.swing.event.DocumentEvent e) {
onFieldEdited();
}
}
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) {
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
p.add(label, c);
c.gridx = 1;
c.weightx = 1;
p.add(field, c);
}
}

View File

@@ -80,6 +80,7 @@ public final class ConnectDialog extends JDialog {
getContentPane().add(form, BorderLayout.CENTER); getContentPane().add(form, BorderLayout.CENTER);
getContentPane().add(buttons, BorderLayout.SOUTH); getContentPane().add(buttons, BorderLayout.SOUTH);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE); setDefaultCloseOperation(DISPOSE_ON_CLOSE);
pack(); pack();
setMinimumSize(new Dimension(340, getHeight())); setMinimumSize(new Dimension(340, getHeight()));

View File

@@ -63,13 +63,16 @@ public final class ConnectionInfoDialog extends JDialog {
this.clientId = clientId; this.clientId = clientId;
JPanel content = new JPanel(new BorderLayout(0, 10)); JPanel content = new JPanel(new BorderLayout(0, 10));
content.setBackground(Theme.WINDOW_BG); content.setBackground(Theme.windowBg());
content.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14)); content.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14));
content.add(buildSummary(), BorderLayout.NORTH); content.add(buildSummary(), BorderLayout.NORTH);
content.add(buildTabs(), BorderLayout.CENTER); content.add(buildTabs(), BorderLayout.CENTER);
content.add(buildButtons(), BorderLayout.SOUTH); content.add(buildButtons(), BorderLayout.SOUTH);
setContentPane(content); setContentPane(content);
Dialogs.closeOnEscape(this);
// Disposing is what stops the refresh timer, so the window button must not merely hide.
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
pack(); pack();
setLocationRelativeTo(owner); setLocationRelativeTo(owner);
@@ -89,7 +92,7 @@ public final class ConnectionInfoDialog extends JDialog {
private JPanel buildSummary() { private JPanel buildSummary() {
JPanel grid = new JPanel(new GridBagLayout()); JPanel grid = new JPanel(new GridBagLayout());
grid.setBackground(Theme.WINDOW_BG); grid.setBackground(Theme.windowBg());
int row = 0; int row = 0;
addRow(grid, row++, "Address", addressValue); addRow(grid, row++, "Address", addressValue);
addRow(grid, row++, "Client version", versionValue); addRow(grid, row++, "Client version", versionValue);
@@ -103,8 +106,8 @@ public final class ConnectionInfoDialog extends JDialog {
private JTabbedPane buildTabs() { private JTabbedPane buildTabs() {
JTabbedPane tabs = new JTabbedPane(); JTabbedPane tabs = new JTabbedPane();
tabs.setFont(Theme.UI_FONT); tabs.setFont(Theme.uiFont());
tabs.setBackground(Theme.WINDOW_BG); tabs.setBackground(Theme.windowBg());
tabs.addTab("Total", totalTab); tabs.addTab("Total", totalTab);
tabs.addTab("Speech", speechTab); tabs.addTab("Speech", speechTab);
tabs.addTab("Keep Alive", keepAliveTab); tabs.addTab("Keep Alive", keepAliveTab);
@@ -115,7 +118,7 @@ public final class ConnectionInfoDialog extends JDialog {
private JPanel buildButtons() { private JPanel buildButtons() {
JPanel bar = new JPanel(); JPanel bar = new JPanel();
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS)); bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
bar.setBackground(Theme.WINDOW_BG); bar.setBackground(Theme.windowBg());
bar.add(Box.createHorizontalGlue()); bar.add(Box.createHorizontalGlue());
JButton close = new JButton("Close"); JButton close = new JButton("Close");
close.addActionListener(e -> dispose()); close.addActionListener(e -> dispose());
@@ -125,8 +128,8 @@ public final class ConnectionInfoDialog extends JDialog {
private static JLabel value() { private static JLabel value() {
JLabel l = new JLabel(""); JLabel l = new JLabel("");
l.setFont(Theme.UI_FONT); l.setFont(Theme.uiFont());
l.setForeground(Theme.TREE_TEXT); l.setForeground(Theme.treeText());
return l; return l;
} }
@@ -137,7 +140,7 @@ public final class ConnectionInfoDialog extends JDialog {
lc.anchor = GridBagConstraints.WEST; lc.anchor = GridBagConstraints.WEST;
lc.insets = new Insets(2, 0, 2, 16); lc.insets = new Insets(2, 0, 2, 16);
JLabel key = new JLabel(label); JLabel key = new JLabel(label);
key.setFont(Theme.UI_FONT); key.setFont(Theme.uiFont());
key.setForeground(new Color(0x5A6B7B)); key.setForeground(new Color(0x5A6B7B));
grid.add(key, lc); grid.add(key, lc);
@@ -252,7 +255,7 @@ public final class ConnectionInfoDialog extends JDialog {
KindTab() { KindTab() {
super(new GridBagLayout()); super(new GridBagLayout());
setBackground(Theme.WINDOW_BG); setBackground(Theme.windowBg());
setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12)); setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
int row = 0; int row = 0;
addRow(this, row++, "Packet loss", packetLoss); addRow(this, row++, "Packet loss", packetLoss);

View File

@@ -0,0 +1,171 @@
package com.ts3client.ui;
import com.ts3client.text.BBCode;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.JButton;
import javax.swing.JColorChooser;
import javax.swing.JDialog;
import javax.swing.JEditorPane;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Window;
/**
* The description editor TeamSpeak tears off the channel dialog: the raw BBCode plus
* the handful of formatting buttons it offers (bold, italic, underline, colour) and a
* preview that renders the text the way the info panel will.
*
* <p>Formatting works on the selection, wrapping it in the tag pair; with nothing
* selected the pair is inserted and the caret placed between the two halves, so typing
* continues inside it.
*/
final class DescriptionEditorDialog extends JDialog {
private static final String EDIT_CARD = "edit";
private static final String PREVIEW_CARD = "preview";
private final JTextArea area = new JTextArea();
private final JEditorPane preview = new JEditorPane("text/html", "");
private final CardLayout cards = new CardLayout();
private final JPanel body = new JPanel(cards);
private final JToggleButton previewButton = new JToggleButton("Preview");
private boolean accepted;
DescriptionEditorDialog(Window owner, String description) {
super(owner, "Channel Description", ModalityType.APPLICATION_MODAL);
area.setText(description);
area.setLineWrap(true);
area.setWrapStyleWord(true);
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
area.setCaretPosition(0);
preview.setEditable(false);
preview.setBackground(Theme.chatBg());
preview.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
body.add(new JScrollPane(area), EDIT_CARD);
body.add(new JScrollPane(preview), PREVIEW_CARD);
JLabel hint = new JLabel("Press Button to \"Preview\" the changes in channel info.");
hint.setForeground(Theme.chatSystem());
hint.setBorder(BorderFactory.createEmptyBorder(4, 6, 0, 6));
getContentPane().setLayout(new BorderLayout());
getContentPane().add(buildToolbar(), BorderLayout.NORTH);
getContentPane().add(body, BorderLayout.CENTER);
JPanel south = new JPanel(new BorderLayout());
south.add(hint, BorderLayout.NORTH);
south.add(buildButtons(), BorderLayout.SOUTH);
getContentPane().add(south, BorderLayout.SOUTH);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(520, 380));
setMinimumSize(new Dimension(360, 260));
setLocationRelativeTo(owner);
}
/** @return whether the user confirmed; {@link #getDescription()} then holds the new text */
boolean isAccepted() {
return accepted;
}
String getDescription() {
return area.getText();
}
private JToolBar buildToolbar() {
JToolBar bar = new JToolBar();
bar.setFloatable(false);
bar.add(tagButton("B", "Bold", Font.BOLD, "[b]", "[/b]"));
bar.add(tagButton("I", "Italic", Font.ITALIC, "[i]", "[/i]"));
JButton underline = tagButton("<html><u>U</u></html>", "Underline", Font.PLAIN, "[u]", "[/u]");
bar.add(underline);
JButton color = new JButton("Color");
color.setToolTipText("Color");
color.addActionListener(a -> chooseColor());
bar.add(square(color));
bar.add(Box.createHorizontalGlue());
previewButton.setToolTipText("Show the description as the info panel renders it");
previewButton.addActionListener(a -> showPreview(previewButton.isSelected()));
previewButton.setMaximumSize(previewButton.getPreferredSize());
bar.add(previewButton);
return bar;
}
private JPanel buildButtons() {
JButton ok = new JButton("OK");
JButton cancel = new JButton("Cancel");
ok.addActionListener(a -> {
accepted = true;
dispose();
});
cancel.addActionListener(a -> dispose());
JPanel panel = new JPanel(new BorderLayout());
JPanel right = new JPanel();
right.add(ok);
right.add(cancel);
panel.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(ok);
return panel;
}
private JButton tagButton(String text, String tip, int style, String open, String close) {
JButton button = new JButton(text);
button.setToolTipText(tip);
button.setFont(button.getFont().deriveFont(style));
button.addActionListener(a -> wrapSelection(open, close));
return square(button);
}
/**
* Pins a toolbar button to its natural size: the tool bar lays its children out along a
* box, which would otherwise let one of them soak up all the free width.
*/
private static JButton square(JButton button) {
Dimension size = new Dimension(Math.max(28, button.getPreferredSize().width),
button.getPreferredSize().height);
button.setPreferredSize(size);
button.setMaximumSize(size);
return button;
}
private void chooseColor() {
Color chosen = JColorChooser.showDialog(this, "Color", Color.BLACK);
if (chosen == null) return;
wrapSelection(String.format("[color=#%02x%02x%02x]",
chosen.getRed(), chosen.getGreen(), chosen.getBlue()), "[/color]");
}
/** Wraps the selection (or the caret) in a BBCode tag pair and returns focus to the text. */
private void wrapSelection(String open, String close) {
if (previewButton.isSelected()) showPreview(false);
int start = area.getSelectionStart();
int end = area.getSelectionEnd();
String selected = area.getSelectedText();
area.replaceRange(open + (selected == null ? "" : selected) + close, start, end);
area.setCaretPosition(start + open.length() + (selected == null ? 0 : selected.length()));
area.requestFocusInWindow();
}
private void showPreview(boolean on) {
previewButton.setSelected(on);
if (on) {
preview.setText("<html><body style=\"font-family:sans-serif;font-size:9pt\">"
+ BBCode.toHtml(area.getText()) + "</body></html>");
preview.setCaretPosition(0);
}
cards.show(body, on ? PREVIEW_CARD : EDIT_CARD);
}
}

View File

@@ -0,0 +1,234 @@
package com.ts3client.ui;
import com.ts3client.config.Settings;
import com.ts3client.gfx.IconPack;
import javax.swing.BorderFactory;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.SwingConstants;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.io.File;
/**
* Options page for how the client looks: the light or dark look-and-feel, and the icon
* pack the user interface is drawn with — where to look for more of them, and a viewer
* showing everything the active pack contains.
*
* <p>Both apply right away so the change can be seen in the window behind the dialog;
* cancelling puts the previous ones back.
*/
final class DesignPanel extends JPanel {
/** Size of the tiles in the icon viewer. */
private static final int PREVIEW_SIZE = 32;
private static final int TILE = 44;
/** Stands for "no pack": the icons the client draws itself. */
private static final String BUILT_IN = "Built-in icons";
private final Settings settings;
private final String originalPackId;
private final String originalPackDir;
private final Settings.Appearance originalAppearance;
private final JComboBox<Settings.Appearance> appearanceCombo =
new JComboBox<>(Settings.Appearance.values());
private final JComboBox<Object> packCombo = new JComboBox<>();
private final JLabel packInfo = new JLabel();
private final JTextField packDirField;
private final DefaultListModel<String> previewModel = new DefaultListModel<>();
private final JList<String> preview = new JList<>(previewModel);
DesignPanel(Settings settings) {
super(new BorderLayout(0, 8));
this.settings = settings;
this.originalPackId = settings.iconPack;
this.originalPackDir = settings.iconPackDir;
this.originalAppearance = settings.appearance;
this.packDirField = new JTextField(settings.iconPackDir, 18);
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
add(buildHeader(), BorderLayout.NORTH);
add(buildPreview(), BorderLayout.CENTER);
reloadPacks();
}
/** Copies the chosen appearance and pack into the settings; the caller saves them. */
void apply() {
settings.appearance = selectedAppearance();
settings.iconPackDir = packDirField.getText().trim();
Object selected = packCombo.getSelectedItem();
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
}
/** Puts back the appearance and pack the dialog started with, for a cancelled edit. */
void revert() {
settings.iconPack = originalPackId;
settings.iconPackDir = originalPackDir;
IconTheme.get().reload(settings);
if (settings.appearance != originalAppearance) {
settings.appearance = originalAppearance;
LookAndFeelManager.switchTo(originalAppearance);
}
}
// ---- layout ----
private JComponent buildHeader() {
JPanel p = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(2, 2, 2, 2);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
packCombo.setToolTipText("Icon packs installed here or in a TeamSpeak 3 client");
packCombo.addActionListener(e -> onPackSelected());
packCombo.setRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focus) {
super.getListCellRendererComponent(list, value, index, selected, focus);
setText(value instanceof IconPack pack ? pack.name() : String.valueOf(value));
return this;
}
});
appearanceCombo.setToolTipText("Light or dark window colours");
appearanceCombo.setSelectedItem(settings.appearance);
appearanceCombo.setRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focus) {
super.getListCellRendererComponent(list, value, index, selected, focus);
setText(value == Settings.Appearance.DARK ? "Dark" : "Light");
return this;
}
});
appearanceCombo.addActionListener(e -> onAppearanceSelected());
int row = 0;
addRow(p, c, row++, new JLabel("Theme:"), appearanceCombo);
addRow(p, c, row++, new JLabel("Icon pack:"), packCombo);
c.gridx = 1;
c.gridy = row++;
packInfo.setEnabled(false);
p.add(packInfo, c);
JPanel dirRow = new JPanel(new BorderLayout(6, 0));
JButton browse = new JButton("Browse…");
browse.addActionListener(e -> browseForPackFolder());
dirRow.add(packDirField, BorderLayout.CENTER);
dirRow.add(browse, BorderLayout.EAST);
packDirField.setToolTipText("Extra folder to look for icon packs in");
addRow(p, c, row, new JLabel("Extra pack folder:"), dirRow);
return p;
}
private JComponent buildPreview() {
preview.setLayoutOrientation(JList.HORIZONTAL_WRAP);
preview.setVisibleRowCount(-1);
preview.setFixedCellWidth(TILE);
preview.setFixedCellHeight(TILE);
preview.setCellRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focus) {
super.getListCellRendererComponent(list, value, index, selected, focus);
String key = String.valueOf(value);
ImageIcon icon = Icons.of(key, PREVIEW_SIZE);
setText("");
setIcon(icon);
setToolTipText(key);
setHorizontalAlignment(SwingConstants.CENTER);
return this;
}
});
JScrollPane scroll = new JScrollPane(preview);
scroll.setPreferredSize(new Dimension(360, 200));
scroll.setBorder(BorderFactory.createTitledBorder("Icons in this pack"));
return scroll;
}
// ---- behaviour ----
private void reloadPacks() {
IconTheme theme = IconTheme.get();
theme.reload(settings);
// Filling the combo box changes its selection, and with it the active pack,
// so remember the one the settings asked for before touching it.
IconPack selected = theme.activePack();
packCombo.removeAllItems();
packCombo.addItem(BUILT_IN);
for (IconPack pack : theme.packs()) packCombo.addItem(pack);
packCombo.setSelectedItem(selected == null ? BUILT_IN : selected);
onPackSelected();
}
private Settings.Appearance selectedAppearance() {
Object selected = appearanceCombo.getSelectedItem();
return selected instanceof Settings.Appearance a ? a : Settings.Appearance.LIGHT;
}
private void onAppearanceSelected() {
Settings.Appearance chosen = selectedAppearance();
if (chosen == settings.appearance) return;
settings.appearance = chosen;
LookAndFeelManager.switchTo(chosen);
}
private void onPackSelected() {
Object selected = packCombo.getSelectedItem();
IconPack pack = selected instanceof IconPack p ? p : null;
IconTheme.get().setPack(pack);
previewModel.clear();
if (pack == null) {
packInfo.setText("Drawn by the client itself");
} else {
for (String key : pack.keys()) previewModel.addElement(key);
packInfo.setText(pack.keys().size() + " icons"
+ (pack.fallsBack() ? ", missing ones from the default pack" : ""));
}
}
private void browseForPackFolder() {
JFileChooser chooser = new JFileChooser();
chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
chooser.setDialogTitle("Folder containing icon packs");
String current = packDirField.getText().trim();
if (!current.isEmpty()) chooser.setCurrentDirectory(new File(current));
if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return;
packDirField.setText(chooser.getSelectedFile().getAbsolutePath());
settings.iconPackDir = packDirField.getText();
reloadPacks();
}
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) {
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
p.add(label, c);
c.gridx = 1;
c.weightx = 1;
p.add(field, c);
}
}

View File

@@ -0,0 +1,160 @@
package com.ts3client.ui;
import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput;
import com.ts3client.audio.desktop.AudioDevices;
import com.ts3client.config.Settings;
import javax.swing.Box;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JSlider;
import java.awt.GridBagConstraints;
import java.awt.Insets;
import java.util.List;
import java.util.function.Consumer;
/**
* Options dialog's "Playback / Capture" tab: device pickers, gain/volume, and the
* noise-reduction pre-processing options. Gain and pre-processing changes are pushed
* live via {@code applyLive}, which reaches both the connected microphone and the
* dialog's own microphone test.
*/
final class DevicesPanel extends FormPanel {
private final JComboBox<AudioDevices.Device> inputCombo;
private final JComboBox<AudioDevices.Device> outputCombo;
private final JSlider inputGain;
private final JSlider outputVol;
private final JCheckBox denoiseCheck;
private final JSlider denoiseLevel;
private final JCheckBox typingCheck;
private final JCheckBox agcCheck;
DevicesPanel(Settings settings, VoiceOutput livePlayback,
Consumer<Consumer<VoiceInput>> applyLive,
Runnable onInputDeviceChanged, Consumer<String> onOutputDeviceChanged) {
GridBagConstraints c = gbc();
List<AudioDevices.Device> ins = AudioDevices.inputDevices();
List<AudioDevices.Device> outs = AudioDevices.outputDevices();
inputCombo = new JComboBox<>(ins.toArray(new AudioDevices.Device[0]));
outputCombo = new JComboBox<>(outs.toArray(new AudioDevices.Device[0]));
selectOrDefault(inputCombo, settings.inputDevice);
selectOrDefault(outputCombo, settings.outputDevice);
String deviceHint = "<html>Named devices are PipeWire's, and are routed through it "
+ "(so per-application volume and rerouting keep working).<br>"
+ "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>";
inputCombo.setToolTipText(deviceHint);
outputCombo.setToolTipText(deviceHint);
limitWidth(inputCombo, FIELD_WIDTH);
limitWidth(outputCombo, FIELD_WIDTH);
int row = 0;
addRow(this, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
addRow(this, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
limitWidth(inputGain, SLIDER_WIDTH);
limitWidth(outputVol, SLIDER_WIDTH);
addRow(this, c, row++, new JLabel("Microphone gain:"), inputGain);
addRow(this, c, row++, new JLabel("Playback volume:"), outputVol);
outputVol.addChangeListener(e -> {
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
});
inputGain.addChangeListener(e ->
applyLive.accept(m -> m.setInputGain(inputGain.getValue() / 100.0)));
inputCombo.addActionListener(e -> onInputDeviceChanged.run());
outputCombo.addActionListener(e -> onOutputDeviceChanged.accept(comboValue(outputCombo)));
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
c.insets = new Insets(14, 4, 2, 4);
add(new JLabel("Noise reduction"), c);
c.insets = new Insets(4, 4, 4, 4);
c.gridwidth = 1;
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
denoiseCheck.setToolTipText("Attempt to filter out background noises.");
denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100));
limitWidth(denoiseLevel, SLIDER_WIDTH);
denoiseLevel.setToolTipText("Higher = more aggressive noise removal.");
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
+ "reduce the sounds made by typing.</html>");
agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc);
agcCheck.setToolTipText("<html><b>Automatic gain control</b> normalises your "
+ "microphone loudness to a target level, boosting quiet mics and taming "
+ "loud ones.</html>");
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
add(denoiseCheck, c);
c.gridwidth = 1;
addRow(this, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
add(typingCheck, c);
c.gridy = row++;
add(agcCheck, c);
c.gridwidth = 1;
Runnable syncNoise = () -> {
denoiseLevel.setEnabled(denoiseCheck.isSelected());
applyLive.accept(m -> {
m.setNoiseSuppression(denoiseCheck.isSelected());
m.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
m.setTypingAttenuation(typingCheck.isSelected());
m.setAgc(agcCheck.isSelected());
});
};
denoiseCheck.addActionListener(e -> syncNoise.run());
typingCheck.addActionListener(e -> syncNoise.run());
agcCheck.addActionListener(e -> syncNoise.run());
denoiseLevel.addChangeListener(e ->
applyLive.accept(m -> m.setDenoiserLevel(denoiseLevel.getValue() / 100.0)));
syncNoise.run();
c.gridx = 0;
c.gridy = row;
c.weighty = 1;
add(Box.createGlue(), c);
}
/** Copies the form into {@code target}, without touching anything else. */
void writeInto(Settings target) {
target.inputDevice = comboValue(inputCombo);
target.outputDevice = comboValue(outputCombo);
target.inputVolume = inputGain.getValue() / 100.0;
target.outputVolume = outputVol.getValue() / 100.0;
target.denoise = denoiseCheck.isSelected();
target.denoiserLevel = denoiseLevel.getValue() / 100.0;
target.typingAttenuation = typingCheck.isSelected();
target.agc = agcCheck.isSelected();
}
private static void selectOrDefault(JComboBox<AudioDevices.Device> combo, String deviceId) {
if (deviceId != null && !deviceId.isEmpty()) {
for (int i = 0; i < combo.getItemCount(); i++) {
if (deviceId.equals(combo.getItemAt(i).id())) {
combo.setSelectedIndex(i);
return;
}
}
}
combo.setSelectedIndex(0);
}
private static String comboValue(JComboBox<AudioDevices.Device> combo) {
AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem();
return d == null ? "" : d.id();
}
}

View File

@@ -0,0 +1,44 @@
package com.ts3client.ui;
import javax.swing.AbstractAction;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JRootPane;
import javax.swing.KeyStroke;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
/** Behaviour shared by the client's dialogs. */
final class Dialogs {
private Dialogs() {
}
/**
* Makes Escape close a dialog, the way Swing's own option panes do.
*
* <p>The binding lives on the root pane's window-wide input map, so it fires
* wherever the focus sits — but only where nothing nearer to the focused
* component claims Escape first, which is what leaves a table's or combo
* box's own "cancel the edit" behaviour intact.
*/
static void closeOnEscape(JDialog dialog) {
closeOnEscape(dialog, dialog::dispose);
}
/**
* Binds Escape to the dialog's own way of closing, for dialogs that have to
* tidy up (or discard edits) on the way out.
*/
static void closeOnEscape(JDialog dialog, Runnable close) {
JRootPane root = dialog.getRootPane();
root.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "ts3-close");
root.getActionMap().put("ts3-close", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
close.run();
}
});
}
}

View File

@@ -0,0 +1,115 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.Icon;
import javax.swing.JButton;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JToggleButton;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Insets;
import java.awt.RenderingHints;
import java.awt.event.ActionListener;
import java.util.function.Supplier;
/**
* A toolbar toggle button with a small arrow next to it that opens a menu:
* clicking the button toggles, clicking the arrow offers the related actions.
*
* <p>The menu is built on every click by the supplier, so it can reflect the
* current state (checked items, the presets that exist right now).
*/
public final class DropDownToggleButton extends JPanel {
private final JToggleButton button = new JToggleButton();
private final JButton arrow = new JButton(new ArrowIcon());
public DropDownToggleButton(Icon icon, String tooltip, Supplier<JPopupMenu> menu) {
super(new BorderLayout());
setOpaque(false);
button.setIcon(icon);
button.setToolTipText(tooltip);
arrow.setToolTipText(tooltip);
arrow.setMargin(new Insets(0, 2, 0, 2));
arrow.setFocusable(false);
arrow.addActionListener(e -> {
JPopupMenu popup = menu.get();
if (popup != null) popup.show(this, 0, getHeight());
});
// Keep the whole control the size of a plain toolbar button: the arrow's
// width comes out of the toggle half instead of being added to it.
Dimension plain = button.getPreferredSize();
button.setPreferredSize(new Dimension(
Math.max(icon.getIconWidth() + 8, plain.width - arrow.getPreferredSize().width),
plain.height));
add(button, BorderLayout.CENTER);
add(arrow, BorderLayout.EAST);
}
/** Listener for the toggle half only; the arrow half opens the menu instead. */
public void addActionListener(ActionListener l) {
button.addActionListener(l);
}
public boolean isSelected() {
return button.isSelected();
}
public void setSelected(boolean selected) {
button.setSelected(selected);
}
public void setIcon(Icon icon) {
button.setIcon(icon);
}
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
button.setEnabled(enabled);
arrow.setEnabled(enabled);
}
/** Disables the toggle half on its own, leaving the menu reachable. */
public void setToggleEnabled(boolean enabled) {
button.setEnabled(enabled);
}
@Override
public Dimension getMaximumSize() {
return getPreferredSize(); // keep the toolbar from stretching us
}
/** The downward triangle on the menu half of the button. */
private static final class ArrowIcon implements Icon {
@Override
public void paintIcon(Component c, Graphics g0, int x, int y) {
Graphics2D g = (Graphics2D) g0.create();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(c.isEnabled() ? Color.DARK_GRAY : Color.GRAY);
int[] xs = {x, x + getIconWidth(), x + getIconWidth() / 2};
int[] ys = {y, y, y + getIconHeight()};
g.fillPolygon(xs, ys, 3);
g.dispose();
}
@Override
public int getIconWidth() {
return 7;
}
@Override
public int getIconHeight() {
return 4;
}
}
}

View File

@@ -0,0 +1,284 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel;
import javax.swing.Icon;
import javax.swing.JTree;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.TreeModel;
import javax.swing.tree.TreePath;
import java.awt.BasicStroke;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
import java.util.function.Predicate;
/**
* The {@link ServerTreePanel} tree, extended to draw where a drag-and-drop would
* land: an insertion line between rows, or an outline around the row that will
* receive the dragged node. Also keeps the server row permanently expanded and
* paints the right-aligned group icon strip, both of which need to hook into
* this component's own paint cycle.
*/
final class DropIndicatorTree extends JTree {
/** Gap kept between a row's label and the right-aligned icon strip. */
private static final int BADGE_GAP = 8;
/** Inset of the strip from the visible right edge. */
private static final int BADGE_MARGIN = 4;
private final ServerModel model;
private final GroupIcons groupIcons;
/** Set while a drop would move a client into this channel row. */
private TreePath highlight;
/** The row the pointer is over, or -1 when it is over none. */
private int hoverRow = -1;
private Predicate<TreePath> pathEditable = path -> false;
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) {
super(treeModel);
this.model = model;
this.groupIcons = groupIcons;
MouseAdapter hover = new MouseAdapter() {
@Override
public void mouseMoved(MouseEvent e) {
setHoverRow(rowAt(e.getY()));
}
@Override
public void mouseDragged(MouseEvent e) {
setHoverRow(-1);
}
@Override
public void mouseExited(MouseEvent e) {
setHoverRow(-1);
}
};
addMouseMotionListener(hover);
addMouseListener(hover);
// The selected row is filled across the full width below, before the rows
// themselves are drawn, so this component must not clear its own background.
setOpaque(false);
}
/**
* The row a point falls on, going by its vertical band alone: a row reaches
* across the whole width, not just as far as its label.
*/
int rowAt(int y) {
int row = getClosestRowForLocation(0, y);
if (row < 0) return -1;
Rectangle bounds = getRowBounds(row);
return bounds != null && y >= bounds.y && y < bounds.y + bounds.height ? row : -1;
}
/** The path a point falls on, by the same rule as {@link #rowAt(int)}. */
TreePath pathAt(int y) {
int row = rowAt(y);
return row < 0 ? null : getPathForRow(row);
}
/**
* Re-reads the row the pointer is over. Scrolling moves the rows under a pointer
* that never moved itself, so the enclosing scroll pane calls this on every scroll.
*/
void refreshHoverRow() {
Point pointer = getMousePosition();
setHoverRow(pointer == null ? -1 : rowAt(pointer.y));
}
private void setHoverRow(int row) {
if (row == hoverRow) return;
repaintRow(hoverRow);
hoverRow = row;
repaintRow(row);
}
private void repaintRow(int row) {
if (row < 0) return;
Rectangle bounds = getRowBounds(row);
if (bounds != null) repaint(0, bounds.y, getWidth(), bounds.height);
}
/** Which rows an inline editor may open on; nothing, until one is installed. */
void setPathEditable(Predicate<TreePath> editable) {
this.pathEditable = editable;
}
@Override
public boolean isPathEditable(TreePath path) {
return isEditable() && pathEditable.test(path);
}
/** Keeps the server row permanently open; collapsing it would hide everything. */
@Override
public void setExpandedState(TreePath path, boolean state) {
if (!state && path.getPathCount() == 1) return;
super.setExpandedState(path, state);
}
/**
* Widens every repaint request to the full visible width. Swing only asks for
* the row rectangle, which stops short of the right-aligned icon strip and would
* leave it behind when a row's label changes width.
*/
@Override
public void repaint(long tm, int x, int y, int width, int height) {
Rectangle visible = getVisibleRect();
super.repaint(tm, visible.x, y, visible.width, height);
}
void highlightChannel(TreePath path) {
if (path == highlight || (path != null && path.equals(highlight))) return;
highlight = path;
repaint();
}
@Override
protected void paintComponent(Graphics g) {
Rectangle clip = g.getClipBounds();
g.setColor(getBackground());
g.fillRect(clip.x, clip.y, clip.width, clip.height);
paintSelection(g);
paintHover(g);
super.paintComponent(g);
paintBadges(g);
JTree.DropLocation loc = getDropLocation();
if (loc == null || loc.getPath() == null) return;
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(Theme.accent());
if (highlight != null || loc.getChildIndex() < 0) {
Rectangle r = getPathBounds(highlight != null ? highlight : loc.getPath());
if (r != null) {
g2.setStroke(new BasicStroke(2f));
g2.drawRoundRect(r.x, r.y + 1, r.width - 1, r.height - 3, 4, 4);
}
} else {
Rectangle line = insertLine(loc);
if (line != null) {
g2.fillRect(line.x, line.y - 1, line.width, 2);
g2.fillOval(line.x - 3, line.y - 4, 7, 7);
}
}
g2.dispose();
}
/**
* Fills the selected row across the full width, in the colour the cell renderer
* puts behind the label itself, so the selection covers the whole line rather
* than stopping where the name ends.
*/
private void paintSelection(Graphics g) {
int[] rows = getSelectionRows();
if (rows == null) return;
Rectangle visible = getVisibleRect();
g.setColor(Theme.treeSelection());
for (int row : rows) {
Rectangle bounds = getRowBounds(row);
if (bounds != null) g.fillRect(visible.x, bounds.y, visible.width, bounds.height);
}
}
/**
* Washes the row under the pointer across the full width it occupies — the same
* area a click on it acts on. A selected row keeps its selection colour, and
* spacers, which nothing can be done with, are left alone.
*/
private void paintHover(Graphics g) {
if (hoverRow < 0 || getDropLocation() != null || isRowSelected(hoverRow)) return;
Rectangle bounds = getRowBounds(hoverRow);
TreePath path = getPathForRow(hoverRow);
if (bounds == null || path == null || isSpacer(path)) return;
Rectangle visible = getVisibleRect();
g.setColor(Theme.hover());
g.fillRect(visible.x, bounds.y, visible.width, bounds.height);
}
private boolean isSpacer(TreePath path) {
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
return obj instanceof ChannelNode && Spacers.isSpacer(((ChannelNode) obj).name);
}
/** The 1px-tall strip where the insertion line goes, in tree coordinates. */
private Rectangle insertLine(JTree.DropLocation loc) {
DefaultMutableTreeNode parent = (DefaultMutableTreeNode) loc.getPath().getLastPathComponent();
int index = loc.getChildIndex();
if (index < parent.getChildCount()) {
Rectangle r = getPathBounds(loc.getPath().pathByAddingChild(parent.getChildAt(index)));
return r == null ? null : new Rectangle(r.x, r.y, getWidth() - r.x, 2);
}
if (parent.getChildCount() == 0) {
Rectangle r = getPathBounds(loc.getPath());
if (r == null) return null;
int x = r.x + getRowHeight();
return new Rectangle(x, r.y + r.height, getWidth() - x, 2);
}
// Past the last child: below that child's whole (expanded) subtree.
TreePath lastChild = loc.getPath().pathByAddingChild(parent.getChildAt(parent.getChildCount() - 1));
Rectangle head = getPathBounds(lastChild);
Rectangle tail = getPathBounds(lastVisibleRow(lastChild));
if (head == null || tail == null) return null;
return new Rectangle(head.x, tail.y + tail.height, getWidth() - head.x, 2);
}
private TreePath lastVisibleRow(TreePath path) {
int row = getRowForPath(path);
if (row < 0) return path;
for (int i = row + 1; i < getRowCount(); i++) {
if (!path.isDescendant(getPathForRow(i))) break;
row = i;
}
return getPathForRow(row);
}
/**
* Paints the icons of every visible row — a client's group icons, a channel's
* own icon — flush with the right edge of the viewport, the way TeamSpeak lines
* them up. Drawing them separately from the cell renderer keeps the rows'
* measured widths (and thus the selection highlight) tied to the label alone.
*/
private void paintBadges(Graphics g) {
Rectangle visible = getVisibleRect();
int right = visible.x + visible.width - BADGE_MARGIN;
for (int row = 0; row < getRowCount(); row++) {
Rectangle bounds = getRowBounds(row);
if (bounds == null || bounds.y + bounds.height < visible.y) continue;
if (bounds.y > visible.y + visible.height) break;
TreePath path = getPathForRow(row);
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
List<Icon> icons = badgesOf(obj);
if (icons.isEmpty()) continue;
GroupIcons.Row strip = new GroupIcons.Row(icons);
int x = Math.max(bounds.x + bounds.width + BADGE_GAP, right - strip.getIconWidth());
strip.paintIcon(this, g, x, bounds.y + (bounds.height - strip.getIconHeight()) / 2);
}
}
/** The icon strip a row shows on its right, empty when it has none (yet). */
private List<Icon> badgesOf(Object node) {
if (node instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) node;
return groupIcons.iconsOf(
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
}
if (node instanceof ChannelNode) {
ChannelNode ch = (ChannelNode) node;
Icon icon = Spacers.isSpacer(ch.name) ? null : groupIcons.icon(ch.iconId);
if (icon != null) return List.of(icon);
}
return List.of();
}
}

View File

@@ -52,9 +52,9 @@ public final class FileBrowserDialog extends JDialog {
private final FileTableModel tableModel = new FileTableModel(); private final FileTableModel tableModel = new FileTableModel();
private final JTable table = new JTable(tableModel); private final JTable table = new JTable(tableModel);
private final JLabel pathLabel = new JLabel("/"); private final JLabel pathLabel = new JLabel("/");
private final JButton upButton = new JButton("Up"); private final JButton upButton = new JButton("Up", Icons.of("FILE_UP"));
private final JButton downloadButton = new JButton("Download"); private final JButton downloadButton = new JButton("Download", Icons.of("DOWNLOAD"));
private final JButton deleteButton = new JButton("Delete"); private final JButton deleteButton = new JButton("Delete", Icons.of("DELETE"));
private final JPanel transfersPanel = new JPanel(); private final JPanel transfersPanel = new JPanel();
private volatile String currentPath = "/"; private volatile String currentPath = "/";
@@ -66,7 +66,7 @@ public final class FileBrowserDialog extends JDialog {
this.channelPassword = ""; this.channelPassword = "";
JPanel content = new JPanel(new BorderLayout(0, 8)); JPanel content = new JPanel(new BorderLayout(0, 8));
content.setBackground(Theme.WINDOW_BG); content.setBackground(Theme.windowBg());
content.setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12)); content.setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
content.add(buildToolbar(), BorderLayout.NORTH); content.add(buildToolbar(), BorderLayout.NORTH);
content.add(buildTable(), BorderLayout.CENTER); content.add(buildTable(), BorderLayout.CENTER);
@@ -85,14 +85,14 @@ public final class FileBrowserDialog extends JDialog {
private JPanel buildToolbar() { private JPanel buildToolbar() {
JPanel bar = new JPanel(); JPanel bar = new JPanel();
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS)); bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
bar.setBackground(Theme.WINDOW_BG); bar.setBackground(Theme.windowBg());
upButton.addActionListener(e -> navigateUp()); upButton.addActionListener(e -> navigateUp());
JButton refresh = new JButton("Refresh"); JButton refresh = new JButton("Refresh", Icons.of("FILE_REFRESH"));
refresh.addActionListener(e -> refresh()); refresh.addActionListener(e -> refresh());
JButton mkdir = new JButton("New folder"); JButton mkdir = new JButton("New folder", Icons.of("ADD_FOLDER"));
mkdir.addActionListener(e -> createDirectory()); mkdir.addActionListener(e -> createDirectory());
JButton upload = new JButton("Upload…"); JButton upload = new JButton("Upload…", Icons.of("UPLOAD"));
upload.addActionListener(e -> chooseUpload()); upload.addActionListener(e -> chooseUpload());
downloadButton.addActionListener(e -> downloadSelected()); downloadButton.addActionListener(e -> downloadSelected());
deleteButton.addActionListener(e -> deleteSelected()); deleteButton.addActionListener(e -> deleteSelected());
@@ -111,17 +111,18 @@ public final class FileBrowserDialog extends JDialog {
bar.add(Box.createHorizontalStrut(6)); bar.add(Box.createHorizontalStrut(6));
bar.add(deleteButton); bar.add(deleteButton);
pathLabel.setFont(Theme.UI_FONT); pathLabel.setFont(Theme.uiFont());
pathLabel.setForeground(Theme.TREE_TEXT); pathLabel.setForeground(Theme.treeText());
return bar; return bar;
} }
private JScrollPane buildTable() { private JScrollPane buildTable() {
table.setFont(Theme.UI_FONT); table.setFont(Theme.uiFont());
table.setRowHeight(20); table.setRowHeight(20);
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
table.setFillsViewportHeight(true); table.setFillsViewportHeight(true);
table.getColumnModel().getColumn(0).setPreferredWidth(300); table.getColumnModel().getColumn(0).setPreferredWidth(300);
table.getColumnModel().getColumn(0).setCellRenderer(new NameRenderer());
table.getColumnModel().getColumn(1).setPreferredWidth(90); table.getColumnModel().getColumn(1).setPreferredWidth(90);
table.getColumnModel().getColumn(2).setPreferredWidth(70); table.getColumnModel().getColumn(2).setPreferredWidth(70);
table.getColumnModel().getColumn(3).setPreferredWidth(150); table.getColumnModel().getColumn(3).setPreferredWidth(150);
@@ -136,17 +137,17 @@ public final class FileBrowserDialog extends JDialog {
} }
}); });
JScrollPane scroll = new JScrollPane(table); JScrollPane scroll = new JScrollPane(table);
scroll.getViewport().setBackground(Theme.TREE_BG); scroll.getViewport().setBackground(Theme.treeBg());
return scroll; return scroll;
} }
private JScrollPane buildTransfers() { private JScrollPane buildTransfers() {
transfersPanel.setLayout(new BoxLayout(transfersPanel, BoxLayout.Y_AXIS)); transfersPanel.setLayout(new BoxLayout(transfersPanel, BoxLayout.Y_AXIS));
transfersPanel.setBackground(Theme.WINDOW_BG); transfersPanel.setBackground(Theme.windowBg());
JScrollPane scroll = new JScrollPane(transfersPanel); JScrollPane scroll = new JScrollPane(transfersPanel);
scroll.setBorder(BorderFactory.createTitledBorder("Transfers")); scroll.setBorder(BorderFactory.createTitledBorder("Transfers"));
scroll.setPreferredSize(new Dimension(10, 120)); scroll.setPreferredSize(new Dimension(10, 120));
scroll.getViewport().setBackground(Theme.WINDOW_BG); scroll.getViewport().setBackground(Theme.windowBg());
return scroll; return scroll;
} }
@@ -325,10 +326,10 @@ public final class FileBrowserDialog extends JDialog {
TransferRow() { TransferRow() {
setLayout(new BorderLayout(8, 0)); setLayout(new BorderLayout(8, 0));
setBackground(Theme.WINDOW_BG); setBackground(Theme.windowBg());
setBorder(BorderFactory.createEmptyBorder(3, 2, 3, 2)); setBorder(BorderFactory.createEmptyBorder(3, 2, 3, 2));
setMaximumSize(new Dimension(Integer.MAX_VALUE, 44)); setMaximumSize(new Dimension(Integer.MAX_VALUE, 44));
label.setFont(Theme.UI_FONT); label.setFont(Theme.uiFont());
bar.setStringPainted(true); bar.setStringPainted(true);
add(label, BorderLayout.NORTH); add(label, BorderLayout.NORTH);
add(bar, BorderLayout.CENTER); add(bar, BorderLayout.CENTER);
@@ -437,6 +438,18 @@ public final class FileBrowserDialog extends JDialog {
} }
} }
/** Marks folders and files with the icon pack's own glyphs. */
private final class NameRenderer extends javax.swing.table.DefaultTableCellRenderer {
@Override
public java.awt.Component getTableCellRendererComponent(JTable t, Object value, boolean selected,
boolean focus, int row, int column) {
super.getTableCellRendererComponent(t, value, selected, focus, row, column);
boolean directory = row >= 0 && row < tableModel.getRowCount() && tableModel.get(row).isDirectory();
setIcon(Icons.of(directory ? "FOLDER" : "DEFAULT"));
return this;
}
}
// ---- formatting ---- // ---- formatting ----
private static String formatBytes(long bytes) { private static String formatBytes(long bytes) {

View File

@@ -0,0 +1,104 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSlider;
import javax.swing.Scrollable;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Rectangle;
/**
* Base for a settings tab's field grid: follows the scroll pane's width instead of
* demanding its own preferred one, so rows stay inside the dialog instead of scrolling
* sideways. Also holds the small GridBagLayout helpers every such tab needs.
*/
class FormPanel extends JPanel implements Scrollable {
static final int FIELD_WIDTH = 240;
static final int SLIDER_WIDTH = 200;
static final int MIN_FIELD_WIDTH = 60;
FormPanel() {
super(new GridBagLayout());
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
}
@Override
public Dimension getPreferredScrollableViewportSize() {
return getPreferredSize();
}
@Override
public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) {
return 16;
}
@Override
public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) {
return visible.height;
}
@Override
public boolean getScrollableTracksViewportWidth() {
return true;
}
@Override
public boolean getScrollableTracksViewportHeight() {
return false;
}
static GridBagConstraints gbc() {
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(4, 4, 4, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
return c;
}
static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) {
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
c.gridwidth = 1;
p.add(label, c);
c.gridx = 1;
c.weightx = 1;
p.add(field, c);
}
/**
* Caps a field's preferred width and lets it shrink: long device names and wide sliders
* would otherwise force the form past the dialog's edge, where the scroll pane (which
* never scrolls horizontally) simply clips them.
*/
static void limitWidth(JComponent comp, int preferredWidth) {
int height = comp.getPreferredSize().height;
comp.setPreferredSize(new Dimension(preferredWidth, height));
comp.setMinimumSize(new Dimension(MIN_FIELD_WIDTH, height));
}
static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
return sliderWithLabel(slider, valueLabel, 48);
}
/**
* Pairs a slider with its value readout. The readout gets a fixed width wide enough for
* the longest value so the slider does not jump around as it is dragged.
*/
static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel, int labelWidth) {
JPanel panel = new JPanel(new BorderLayout(6, 0));
limitWidth(slider, SLIDER_WIDTH);
panel.add(slider, BorderLayout.CENTER);
valueLabel.setPreferredSize(new Dimension(labelWidth, valueLabel.getPreferredSize().height));
panel.add(valueLabel, BorderLayout.EAST);
return panel;
}
}

View File

@@ -37,6 +37,15 @@ public final class GroupIcons {
} }
public ImageIcon icon(long iconId) { public ImageIcon icon(long iconId) {
if (iconId == 0) return null;
// TeamSpeak's default group icons belong to the icon pack, which draws them in
// its own style; only server-uploaded icons come off the wire. The theme keeps
// its own cache, so those are not cached again here.
if (iconId > 0 && iconId <= IconRepository.MAX_BUNDLED_ID) {
ImageIcon themed = IconTheme.icon("GROUP_" + iconId, SIZE);
if (themed != null) return themed;
}
ImageIcon cached = decoded.get(iconId); ImageIcon cached = decoded.get(iconId);
if (cached != null) return cached; if (cached != null) return cached;

View File

@@ -0,0 +1,160 @@
package com.ts3client.ui;
import com.ts3client.hotkey.Hotkey;
import com.ts3client.hotkey.HotkeyEngine;
import javax.swing.SwingUtilities;
import java.util.List;
/**
* Carries a fired hotkey out on the client.
*
* <p>Activations arrive on the input hook's thread, so everything is handed to the
* event dispatch thread first. Which connections an action reaches is the binding's
* "on active server" flag: set, only the selected tab; clear, every connected one.
*/
final class HotkeyActions implements HotkeyEngine.Handler {
/** How much one press of the master-volume hotkeys moves the slider. */
private static final double VOLUME_STEP = 0.05;
private final MainFrame frame;
/** Latched push-to-talk, driven by the "Toggle Push-to-Talk" action. */
private boolean pttLatched;
HotkeyActions(MainFrame frame) {
this.frame = frame;
}
@Override
public void onHotkey(Hotkey hotkey, boolean active) {
SwingUtilities.invokeLater(() -> perform(hotkey, active));
}
private void perform(Hotkey hotkey, boolean active) {
List<ServerTab> targets = frame.hotkeyTargets(hotkey.activeServerOnly);
switch (hotkey.action) {
case CONNECT_CURRENT_TAB -> frame.connectBookmark(hotkey.argument, false);
case CONNECT_NEW_TAB -> frame.connectBookmark(hotkey.argument, true);
case DISCONNECT_CURRENT -> {
ServerTab tab = frame.selectedTab();
if (tab != null) tab.disconnect();
}
case DISCONNECT_ALL -> {
for (ServerTab tab : frame.allTabs()) tab.disconnect();
}
case MIC_ACTIVATE -> frame.moveMicrophoneToSelectedTab();
case MIC_MUTE -> setMicMuted(targets, true);
case MIC_UNMUTE -> setMicMuted(targets, false);
case MIC_TOGGLE -> setMicMuted(targets, !anyMicMuted(targets));
case MIC_LOCAL_MUTE -> setMicLocalMuted(targets, true);
case MIC_LOCAL_UNMUTE -> setMicLocalMuted(targets, false);
case MIC_LOCAL_TOGGLE -> setMicLocalMuted(targets, !anyMicLocalMuted(targets));
case SPEAKER_MUTE -> setDeafened(targets, true);
case SPEAKER_UNMUTE -> setDeafened(targets, false);
case SPEAKER_TOGGLE -> setDeafened(targets, !anyDeafened(targets));
case AWAY_SET -> setAway(targets, true, "");
case AWAY_ONLINE -> setAway(targets, false, "");
case AWAY_TOGGLE -> setAway(targets, !anyAway(targets), "");
case AWAY_TOGGLE_WITH_MESSAGE -> setAway(targets, !anyAway(targets), hotkey.argument);
case COMMANDER_ACTIVATE -> setCommander(targets, true);
case COMMANDER_DEACTIVATE -> setCommander(targets, false);
case COMMANDER_TOGGLE -> setCommander(targets, !anyCommander(targets));
// Momentary: the engine reports the release too, so the key simply holds it open.
case PTT_ACTIVATE -> frame.setPushToTalk(active || pttLatched);
case PTT_DEACTIVATE -> {
pttLatched = false;
frame.setPushToTalk(false);
}
case PTT_TOGGLE -> {
pttLatched = !pttLatched;
frame.setPushToTalk(pttLatched);
}
case CHANNEL_SWITCH -> {
for (ServerTab tab : targets) tab.joinChannelPath(hotkey.argument);
}
case SERVER_TAB_SELECT -> frame.selectTabNumber(parseIndex(hotkey.argument));
case SERVER_TAB_NEXT -> frame.stepTab(1);
case SERVER_TAB_PREVIOUS -> frame.stepTab(-1);
case SOUND_MUTE -> frame.setSoundsMuted(true);
case SOUND_UNMUTE -> frame.setSoundsMuted(false);
case SOUND_TOGGLE -> frame.setSoundsMuted(!frame.areSoundsMuted());
case VOLUME_INCREASE -> frame.adjustMasterVolume(VOLUME_STEP);
case VOLUME_DECREASE -> frame.adjustMasterVolume(-VOLUME_STEP);
case NICKNAME_CHANGE -> frame.changeNickname(hotkey.argument);
case FILEBROWSER -> frame.browseCurrentChannel();
case SKIN_RELOAD -> frame.reloadSkin();
case BRING_TO_FRONT -> frame.bringToFront();
case SEND_TO_BACK -> frame.sendToBack();
default -> {
// Listed for completeness in the action catalogue, but not implemented here.
}
}
}
private void setMicMuted(List<ServerTab> targets, boolean muted) {
for (ServerTab tab : targets) tab.setMicMuted(muted);
frame.refreshAfterHotkey();
}
private void setMicLocalMuted(List<ServerTab> targets, boolean muted) {
for (ServerTab tab : targets) tab.setMicLocalMuted(muted);
frame.refreshAfterHotkey();
}
private void setDeafened(List<ServerTab> targets, boolean deaf) {
for (ServerTab tab : targets) tab.setDeafened(deaf);
frame.refreshAfterHotkey();
}
private void setAway(List<ServerTab> targets, boolean away, String message) {
for (ServerTab tab : targets) tab.setAway(away, message == null ? "" : message);
frame.refreshAfterHotkey();
}
private void setCommander(List<ServerTab> targets, boolean commander) {
for (ServerTab tab : targets) tab.setCommander(commander);
frame.refreshAfterHotkey();
}
private static boolean anyMicMuted(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isMicMuted);
}
private static boolean anyMicLocalMuted(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isMicLocalMuted);
}
private static boolean anyDeafened(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isDeafened);
}
private static boolean anyAway(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isAway);
}
private static boolean anyCommander(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isCommander);
}
/** @return the 1-based tab number in the argument, or 1 when it is not a number */
private static int parseIndex(String argument) {
try {
return Math.max(1, Integer.parseInt(argument.trim()));
} catch (RuntimeException e) {
return 1;
}
}
}

View File

@@ -0,0 +1,16 @@
package com.ts3client.ui;
import com.ts3client.hotkey.HotkeyAction;
import java.util.List;
/**
* Supplies the concrete values an action's parameter can take — the bookmarks, sound
* packs and channels the hotkey tree hangs under an action as its leaves, so a binding
* reads as "Sounds / Activate Soundpack / Default Sound Pack (Male)".
*/
interface HotkeyArguments {
/** @return the choices for this action, or an empty list when it is free-form */
List<String> choices(HotkeyAction action);
}

View File

@@ -0,0 +1,385 @@
package com.ts3client.ui;
import com.ts3client.hotkey.Hotkey;
import com.ts3client.hotkey.HotkeyAction;
import com.ts3client.hotkey.HotkeyCombo;
import com.ts3client.hotkey.HotkeyEngine;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JTree;
import javax.swing.SwingUtilities;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.TreePath;
import javax.swing.tree.TreeSelectionModel;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Window;
import java.util.List;
/**
* Adds or edits one hotkey, following the official client's dialog: pick the action
* from the tree — category, action group, action, and where the action takes one, the
* concrete bookmark, sound pack or channel — press the key combination to bind, and
* choose the edge it triggers on and whether it applies to the active server only.
*/
final class HotkeyDialog extends JDialog {
/**
* A tree node: a category or group heading ({@code action == null}), an action, or
* one of the values an action's parameter can take ({@code argument != null}).
*/
private record Node(HotkeyAction action, String argument, String label) {
static Node heading(String label) {
return new Node(null, null, label);
}
static Node of(HotkeyAction action) {
return new Node(action, null, action.label());
}
static Node value(HotkeyAction action, String argument) {
return new Node(action, argument, argument);
}
boolean isHeading() {
return action == null;
}
@Override
public String toString() {
return label;
}
}
private final HotkeyService service;
private final Hotkey hotkey;
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode("Actions");
private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
private final JTree tree = new JTree(treeModel);
private final JCheckBox advancedCheck = new JCheckBox("Show advanced actions");
private final JButton keyButton = new JButton();
private final JComboBox<Hotkey.Trigger> triggerCombo = new JComboBox<>(Hotkey.Trigger.values());
private final JCheckBox activeServerCheck = new JCheckBox("On active server");
private final JLabel argumentLabel = new JLabel();
private final JTextField argumentField = new JTextField();
private final JLabel hint = new JLabel();
private HotkeyCombo combo;
private boolean recording;
private boolean confirmed;
HotkeyDialog(Window owner, HotkeyService service, Hotkey existing) {
// Any window may open this: the options dialog's hotkey tab as much as a frame.
super(owner, existing == null ? "Add hotkey" : "Edit hotkey", ModalityType.APPLICATION_MODAL);
this.service = service;
this.hotkey = existing == null ? new Hotkey() : existing.copy();
this.combo = hotkey.combo;
advancedCheck.setSelected(hotkey.action != null && hotkey.action.advanced());
advancedCheck.addActionListener(e -> rebuildTree());
tree.setRootVisible(false);
tree.setShowsRootHandles(true);
tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
tree.setCellRenderer(new NodeRenderer());
tree.addTreeSelectionListener(e -> selectionChanged());
rebuildTree();
keyButton.addActionListener(e -> startRecording());
triggerCombo.setRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focused) {
super.getListCellRendererComponent(list, value, index, selected, focused);
if (value instanceof Hotkey.Trigger t) setText(t.label());
return this;
}
});
triggerCombo.setSelectedItem(hotkey.trigger);
activeServerCheck.setSelected(hotkey.activeServerOnly);
argumentField.setText(hotkey.argument == null ? "" : hotkey.argument);
hint.setFont(hint.getFont().deriveFont(Font.ITALIC, hint.getFont().getSize2D() - 1f));
getContentPane().setLayout(new BorderLayout(8, 8));
((JComponent) getContentPane()).setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
getContentPane().add(buildActionPane(), BorderLayout.CENTER);
getContentPane().add(buildForm(), BorderLayout.SOUTH);
updateKeyButton();
updateForAction();
Dialogs.closeOnEscape(this, this::cancel);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(470, 560));
setLocationRelativeTo(owner);
}
private JPanel buildActionPane() {
JPanel p = new JPanel(new BorderLayout(0, 4));
p.add(new JLabel("Action:"), BorderLayout.NORTH);
p.add(new JScrollPane(tree), BorderLayout.CENTER);
p.add(advancedCheck, BorderLayout.SOUTH);
return p;
}
private JPanel buildForm() {
JPanel p = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 3, 3, 3);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
int row = 0;
addRow(p, c, row++, new JLabel("Hotkey:"), keyButton);
addRow(p, c, row++, argumentLabel, argumentField);
addRow(p, c, row++, new JLabel("Trigger:"), triggerCombo);
c.gridx = 1;
c.gridy = row++;
p.add(activeServerCheck, c);
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(hint, c);
JPanel buttons = new JPanel();
JButton ok = new JButton("OK");
JButton cancel = new JButton("Cancel");
ok.addActionListener(e -> confirm());
cancel.addActionListener(e -> cancel());
buttons.add(Box.createHorizontalGlue());
buttons.add(ok);
buttons.add(cancel);
c.gridy = row;
p.add(buttons, c);
getRootPane().setDefaultButton(ok);
return p;
}
private static void addRow(JPanel p, GridBagConstraints c, int row, JComponent left, JComponent right) {
c.gridwidth = 1;
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
p.add(left, c);
c.gridx = 1;
c.weightx = 1;
p.add(right, c);
}
/**
* Builds category → group → action → value, collapsing the groups TS3 leaves
* unnamed so their actions sit directly under the category.
*/
private void rebuildTree() {
boolean advanced = advancedCheck.isSelected();
root.removeAllChildren();
for (HotkeyAction.Category category : HotkeyAction.Category.values()) {
List<HotkeyAction> actions = HotkeyAction.of(category, advanced);
if (actions.isEmpty()) continue;
DefaultMutableTreeNode categoryNode =
new DefaultMutableTreeNode(Node.heading(category.label()));
DefaultMutableTreeNode groupNode = null;
String groupName = null;
for (HotkeyAction action : actions) {
DefaultMutableTreeNode parent = categoryNode;
if (!action.group().isEmpty()) {
if (groupNode == null || !action.group().equals(groupName)) {
groupName = action.group();
groupNode = new DefaultMutableTreeNode(Node.heading(groupName));
categoryNode.add(groupNode);
}
parent = groupNode;
}
DefaultMutableTreeNode actionNode = new DefaultMutableTreeNode(Node.of(action));
parent.add(actionNode);
for (String value : service.argumentChoices(action)) {
actionNode.add(new DefaultMutableTreeNode(Node.value(action, value)));
}
}
root.add(categoryNode);
}
treeModel.reload();
// Categories open, groups closed: the whole action set at a glance, as in TS3.
for (int i = 0; i < root.getChildCount(); i++) {
tree.expandPath(new TreePath(((DefaultMutableTreeNode) root.getChildAt(i)).getPath()));
}
selectCurrent();
}
/** Reveals and selects the node matching the binding being edited. */
private void selectCurrent() {
if (hotkey.action == null) return;
DefaultMutableTreeNode match = null;
var nodes = root.depthFirstEnumeration();
while (nodes.hasMoreElements()) {
DefaultMutableTreeNode node = (DefaultMutableTreeNode) nodes.nextElement();
if (!(node.getUserObject() instanceof Node n) || n.action() != hotkey.action) continue;
boolean sameArgument = n.argument() != null && n.argument().equals(hotkey.argument);
if (sameArgument) {
match = node;
break;
}
if (n.argument() == null && match == null) match = node;
}
if (match == null) return;
TreePath path = new TreePath(match.getPath());
tree.setSelectionPath(path);
SwingUtilities.invokeLater(() -> tree.scrollPathToVisible(path));
}
private void selectionChanged() {
if (!(tree.getLastSelectedPathComponent() instanceof DefaultMutableTreeNode node)
|| !(node.getUserObject() instanceof Node selected)) {
return;
}
if (selected.isHeading()) {
// Headings only structure the tree; keep the action that was chosen before.
tree.clearSelection();
selectCurrent();
return;
}
hotkey.action = selected.action();
if (selected.argument() != null) argumentField.setText(selected.argument());
updateForAction();
}
/** Syncs the form to the selected action: parameter row, trigger, scope and hint. */
private void updateForAction() {
HotkeyAction action = hotkey.action;
boolean takesArgument = action != null && action.argument() != HotkeyAction.Argument.NONE;
argumentLabel.setText(takesArgument ? argumentLabel(action) : "");
argumentLabel.setVisible(takesArgument);
argumentField.setVisible(takesArgument);
boolean momentary = action != null && action.momentary();
triggerCombo.setEnabled(!momentary);
activeServerCheck.setEnabled(action != null && action.category() != HotkeyAction.Category.MISC);
if (action != null && !action.supported()) {
hint.setText("This action is part of TeamSpeak's hotkey set but is not implemented yet.");
} else if (momentary) {
hint.setText("Held down: the action lasts as long as the hotkey is pressed.");
} else {
hint.setText(service.isRunning() ? " " : service.status());
}
}
private static String argumentLabel(HotkeyAction action) {
return switch (action.argument()) {
case BOOKMARK -> "Bookmark:";
case CHANNEL -> "Channel path:";
case PROFILE -> "Profile:";
default -> "Parameter:";
};
}
private void startRecording() {
if (recording) return;
if (!service.isRunning()) {
hint.setText(service.status());
return;
}
recording = true;
keyButton.setText("Press hotkey combination…");
service.record(new HotkeyEngine.Recorder() {
@Override
public void onRecording(HotkeyCombo partial) {
SwingUtilities.invokeLater(() -> keyButton.setText(service.display(partial) + ""));
}
@Override
public void onRecorded(HotkeyCombo recorded) {
SwingUtilities.invokeLater(() -> {
combo = recorded;
stopRecording();
});
}
});
}
private void stopRecording() {
if (!recording) return;
recording = false;
service.stopRecording();
updateKeyButton();
}
private void updateKeyButton() {
keyButton.setText(combo == null || combo.isEmpty()
? "No hotkey assigned" : service.display(combo));
}
private void confirm() {
stopRecording();
if (hotkey.action == null || combo == null || combo.isEmpty()) {
hint.setText("Pick an action and press a key combination first.");
return;
}
if (!hotkey.action.supported()) {
hint.setText("This action is not implemented yet — pick another one.");
return;
}
hotkey.combo = combo;
hotkey.trigger = (Hotkey.Trigger) triggerCombo.getSelectedItem();
hotkey.activeServerOnly = activeServerCheck.isSelected();
hotkey.argument = argumentField.getText().trim();
confirmed = true;
dispose();
}
private void cancel() {
stopRecording();
dispose();
}
boolean isConfirmed() {
return confirmed;
}
Hotkey result() {
return hotkey;
}
/** Draws headings in bold and greys out the actions this client cannot perform. */
private static final class NodeRenderer extends DefaultTreeCellRenderer {
@Override
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected,
boolean expanded, boolean leaf, int row,
boolean focused) {
super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, focused);
setIcon(null);
if (!(value instanceof DefaultMutableTreeNode node)
|| !(node.getUserObject() instanceof Node n)) {
return this;
}
if (n.isHeading()) {
setFont(getFont().deriveFont(Font.BOLD));
setToolTipText(null);
} else {
setEnabled(n.action().supported());
setToolTipText(n.action().supported() ? null : "Not implemented by this client");
}
return this;
}
}
}

View File

@@ -0,0 +1,94 @@
package com.ts3client.ui;
import com.ts3client.hotkey.GlobalInputHook;
import com.ts3client.hotkey.Hotkey;
import com.ts3client.hotkey.HotkeyAction;
import com.ts3client.hotkey.HotkeyCombo;
import com.ts3client.hotkey.HotkeyEngine;
import com.ts3client.hotkey.HotkeyKey;
import com.ts3client.hotkey.Hotkeys;
import com.ts3client.hotkey.desktop.DesktopInputHooks;
import java.util.List;
/**
* Owns the hotkey machinery for the UI: the stored bindings, the matching engine and
* the platform input hook they are fed from. Everything the dialogs need — recording a
* combination, naming keys, telling the user why hotkeys are dead — goes through here.
*/
final class HotkeyService {
private final Hotkeys hotkeys = Hotkeys.load();
private final HotkeyEngine engine;
private final GlobalInputHook hook;
private final HotkeyArguments arguments;
HotkeyService(HotkeyEngine.Handler handler, HotkeyArguments arguments) {
this.arguments = arguments;
engine = new HotkeyEngine(hotkeys, handler);
hook = DesktopInputHooks.start(engine);
}
/** The values this action's parameter can take right now, for the action tree. */
List<String> argumentChoices(HotkeyAction action) {
if (arguments == null || action.argument() == HotkeyAction.Argument.NONE) return List.of();
try {
return arguments.choices(action);
} catch (RuntimeException e) {
return List.of();
}
}
List<Hotkey> all() {
return hotkeys.all();
}
void replaceAll(List<Hotkey> updated) {
engine.releaseAll();
hotkeys.replaceAll(updated);
hotkeys.save();
}
/** The binding for an action with no argument, or {@code null} when unbound. */
Hotkey find(HotkeyAction action) {
synchronized (hotkeys.all()) {
for (Hotkey h : hotkeys.all()) {
if (h.action == action) return h;
}
}
return null;
}
boolean isRunning() {
return hook.isRunning();
}
/** One line for the options dialog: either working, or why it is not. */
String status() {
return hook.isRunning()
? "Global hotkeys are active."
: "Global hotkeys are unavailable (" + hook.unavailableReason() + ").";
}
String display(HotkeyCombo combo) {
return combo == null ? "No hotkey assigned" : combo.display(this::keyName);
}
private String keyName(HotkeyKey key) {
return hook.keyName(key);
}
/** Captures the next combination instead of firing bindings; see {@link #stopRecording()}. */
void record(HotkeyEngine.Recorder recorder) {
engine.record(recorder);
}
void stopRecording() {
engine.stopRecording();
}
void dispose() {
engine.releaseAll();
hook.close();
}
}

View File

@@ -0,0 +1,170 @@
package com.ts3client.ui;
import com.ts3client.hotkey.Hotkey;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.Font;
import java.awt.Window;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.List;
/**
* The options dialog's hotkey tab: the list of bindings with the buttons to add, edit
* and remove them. Edits happen on a working copy and only reach the running engine
* when the dialog is applied.
*/
final class HotkeysPanel extends JPanel {
private static final String[] COLUMNS = {"Action", "Hotkey", "Trigger", "Active server", "On"};
private final HotkeyService service;
private final List<Hotkey> working = new ArrayList<>();
private final Model model = new Model();
private final JTable table = new JTable(model);
HotkeysPanel(HotkeyService service) {
super(new BorderLayout(6, 6));
this.service = service;
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
synchronized (service.all()) {
for (Hotkey h : service.all()) working.add(h.copy());
}
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
table.setRowHeight(table.getRowHeight() + 4);
table.getColumnModel().getColumn(0).setPreferredWidth(240);
table.getColumnModel().getColumn(1).setPreferredWidth(150);
table.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 2) edit();
}
});
JButton add = new JButton("Add…");
JButton edit = new JButton("Edit…");
JButton remove = new JButton("Remove");
add.addActionListener(e -> add());
edit.addActionListener(e -> edit());
remove.addActionListener(e -> remove());
JPanel buttons = new JPanel();
buttons.add(add);
buttons.add(edit);
buttons.add(remove);
JLabel status = new JLabel(service.status());
status.setFont(status.getFont().deriveFont(Font.ITALIC, status.getFont().getSize2D() - 1f));
add(new JScrollPane(table), BorderLayout.CENTER);
JPanel south = new JPanel(new BorderLayout());
south.add(buttons, BorderLayout.WEST);
south.add(status, BorderLayout.SOUTH);
add(south, BorderLayout.SOUTH);
}
private void add() {
HotkeyDialog dlg = new HotkeyDialog(owner(), service, null);
dlg.setVisible(true);
if (!dlg.isConfirmed()) return;
working.add(dlg.result());
model.fireTableDataChanged();
}
private void edit() {
int row = table.getSelectedRow();
if (row < 0) return;
HotkeyDialog dlg = new HotkeyDialog(owner(), service, working.get(row));
dlg.setVisible(true);
if (!dlg.isConfirmed()) return;
working.set(row, dlg.result());
model.fireTableRowsUpdated(row, row);
}
private void remove() {
int row = table.getSelectedRow();
if (row < 0) return;
working.remove(row);
model.fireTableDataChanged();
}
private Window owner() {
return SwingUtilities.getWindowAncestor(this);
}
/** Commits the edited list to the engine and to disk. */
void apply() {
service.replaceAll(working);
}
/** Picks the stored bindings up again after something else changed them. */
void reload() {
working.clear();
synchronized (service.all()) {
for (Hotkey h : service.all()) working.add(h.copy());
}
model.fireTableDataChanged();
}
private final class Model extends AbstractTableModel {
@Override
public int getRowCount() {
return working.size();
}
@Override
public int getColumnCount() {
return COLUMNS.length;
}
@Override
public String getColumnName(int column) {
return COLUMNS[column];
}
@Override
public Class<?> getColumnClass(int column) {
return column >= 3 ? Boolean.class : String.class;
}
@Override
public boolean isCellEditable(int row, int column) {
return column == 4 || (column == 3 && working.get(row).isServerScoped());
}
@Override
public Object getValueAt(int row, int column) {
Hotkey h = working.get(row);
return switch (column) {
case 0 -> h.path();
case 1 -> service.display(h.combo);
case 2 -> h.action.momentary() ? "While held" : h.trigger.label();
case 3 -> h.isServerScoped() && h.activeServerOnly;
default -> h.enabled;
};
}
@Override
public void setValueAt(Object value, int row, int column) {
Hotkey h = working.get(row);
if (column == 3) {
h.activeServerOnly = Boolean.TRUE.equals(value);
} else if (column == 4) {
h.enabled = Boolean.TRUE.equals(value);
}
}
}
}

View File

@@ -0,0 +1,108 @@
package com.ts3client.ui;
import com.ts3client.text.BBCode;
import javax.swing.JEditorPane;
import javax.swing.JViewport;
import javax.swing.SwingUtilities;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet;
import java.awt.Color;
/**
* The HTML panes showing TeamSpeak text — chat logs, client and channel information —
* and the theme-dependent stylesheet they share.
*
* <p>Text that is already on screen keeps the colours its stylesheet was built with, so
* a look-and-feel change re-installs the editor kit and parses the pane's content again
* instead of only recolouring what is written afterwards.
*/
final class HtmlStyles {
private static final String BODY_STYLE = "HtmlStyles.bodyStyle";
private HtmlStyles() {
}
/**
* @param bodyStyle extra CSS for {@code body}, e.g. font and margins; the text colour
* is added by the theme
* @return a read-only HTML pane whose stylesheet follows the current theme
*/
static JEditorPane pane(String bodyStyle) {
JEditorPane pane = new JEditorPane() {
@Override
public void updateUI() {
super.updateUI();
restyle(this);
}
};
pane.putClientProperty(BODY_STYLE, bodyStyle);
pane.setEditable(false);
installKit(pane, null, false);
return pane;
}
/**
* Paints the scroll pane {@code pane} sits in with the pane's own background: text
* only reaches as far as it goes, and the empty space below it would otherwise show
* the window's colour instead of the log's.
*/
static void fillViewport(JEditorPane pane) {
if (pane.getParent() instanceof JViewport viewport) {
viewport.setBackground(pane.getBackground());
}
}
/** Re-themes {@code pane}, keeping the text it shows. */
private static void restyle(JEditorPane pane) {
// Also runs from JEditorPane's constructor, before the pane is one of ours.
if (pane.getClientProperty(BODY_STYLE) == null) return;
int length = pane.getDocument().getLength();
String html = length == 0 ? null : pane.getText();
// A chat log sits at its newest line; keep it there rather than jumping to the top.
boolean atEnd = pane.getCaretPosition() >= length;
// Not during the look-and-feel update that got us here: the pane is rebuilding its
// views, and replacing the document underneath it would race with that.
SwingUtilities.invokeLater(() -> installKit(pane, html, atEnd));
}
private static void installKit(JEditorPane pane, String html, boolean atEnd) {
HTMLEditorKit kit = new HTMLEditorKit();
StyleSheet css = new StyleSheet();
css.addStyleSheet(kit.getStyleSheet());
Object bodyStyle = pane.getClientProperty(BODY_STYLE);
css.addRule("body { " + (bodyStyle == null ? "" : bodyStyle + " ")
+ "color:" + hex(Theme.chatText()) + "; }");
// Secondary text (timestamps, labels, placeholders), an author's name and the lines
// about what happened on the server.
css.addRule(".muted { color:" + hex(Theme.chatSystem()) + "; }");
css.addRule(".name { color:" + hex(Theme.chatName()) + "; font-weight:bold; }");
css.addRule(".event { color:" + hex(Theme.chatEvent()) + "; }");
css.addRule("a { color:" + hex(Theme.chatName()) + "; text-decoration:none; }");
// Client references look exactly like a message author's name.
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
+ " { color:" + hex(Theme.chatName()) + "; font-weight:bold; text-decoration:none; }");
// Channel (and other TeamSpeak protocol) references stand out of a line the same
// way a client's name does.
css.addRule("a." + BBCode.TS_LINK_CLASS
+ " { color:" + hex(Theme.chatName()) + "; font-weight:bold; text-decoration:none; }");
// External links are underlined so they can't be mistaken for an identity.
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
+ " { color:" + hex(Theme.link()) + "; text-decoration:underline; }");
kit.setStyleSheet(css);
pane.setEditorKit(kit);
fillViewport(pane);
if (html != null) {
pane.setText(html);
pane.setCaretPosition(atEnd ? pane.getDocument().getLength() : 0);
}
}
private static String hex(Color c) {
return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue());
}
}

View File

@@ -0,0 +1,270 @@
package com.ts3client.ui;
import com.ts3client.net.IconRepository;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.BorderFactory;
import javax.swing.DefaultListModel;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.filechooser.FileNameExtensionFilter;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Window;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.File;
/**
* TeamSpeak's icon viewer: the icons uploaded to this virtual server on one side (which
* can be added to and removed from here) and the ones the icon pack ships on the other.
*
* <p>Server icons arrive through the {@link IconRepository}, which downloads them in the
* background, so the lists are simply repainted until every icon has turned up.
*/
final class IconChooserDialog extends JDialog {
/** How often the lists are repainted while icons are still being downloaded. */
private static final int REFRESH_MS = 300;
/** Give up repainting once nothing has arrived for this long. */
private static final int REFRESH_TIMEOUT_MS = 20_000;
private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
private final DefaultListModel<Long> remoteModel = new DefaultListModel<>();
private final JList<Long> remoteList = new JList<>(remoteModel);
private final JList<Long> localList = new JList<>(new DefaultListModel<>());
private final JButton deleteButton = new JButton("Delete");
private final JButton selectButton = new JButton("Select");
private final JLabel status = new JLabel(" ");
private long chosenIconId;
private boolean accepted;
IconChooserDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons) {
super(owner, "Icons", ModalityType.APPLICATION_MODAL);
this.conn = conn;
this.groupIcons = groupIcons;
configure(remoteList);
configure(localList);
DefaultListModel<Long> localModel = (DefaultListModel<Long>) localList.getModel();
for (Long id : IconRepository.BUNDLED_IDS) localModel.addElement(id);
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
group("Remote", remoteList, remoteButtons()),
group("Local", localList, localFooter()));
split.setResizeWeight(0.6);
status.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8));
status.setForeground(Theme.chatSystem());
getContentPane().setLayout(new BorderLayout());
getContentPane().add(split, BorderLayout.CENTER);
JPanel south = new JPanel(new BorderLayout());
south.add(status, BorderLayout.NORTH);
south.add(buttons(), BorderLayout.SOUTH);
getContentPane().add(south, BorderLayout.SOUTH);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(520, 360));
setLocationRelativeTo(owner);
loadRemoteIcons();
startRefreshing();
}
/** @return whether an icon was picked; {@link #getIconId()} then holds it */
boolean isAccepted() {
return accepted;
}
long getIconId() {
return chosenIconId;
}
private void configure(JList<Long> list) {
list.setLayoutOrientation(JList.HORIZONTAL_WRAP);
list.setVisibleRowCount(-1);
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
list.setFixedCellWidth(32);
list.setFixedCellHeight(32);
list.setCellRenderer(new IconCellRenderer());
list.addListSelectionListener(e -> onSelectionChanged(list));
list.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 2 && list.getSelectedValue() != null) choose(list.getSelectedValue());
}
});
}
/** Keeps one list's selection exclusive: picking on one side clears the other. */
private void onSelectionChanged(JList<Long> list) {
JList<Long> other = list == remoteList ? localList : remoteList;
if (list.getSelectedValue() != null) other.clearSelection();
deleteButton.setEnabled(remoteList.getSelectedValue() != null);
selectButton.setEnabled(selectedIcon() != 0);
}
private long selectedIcon() {
Long remote = remoteList.getSelectedValue();
if (remote != null) return remote;
Long local = localList.getSelectedValue();
return local == null ? 0 : local;
}
private JPanel group(String title, JList<Long> list, Component footer) {
JPanel panel = new JPanel(new BorderLayout(0, 4));
panel.setBorder(BorderFactory.createTitledBorder(title));
panel.add(new JScrollPane(list), BorderLayout.CENTER);
panel.add(footer, BorderLayout.SOUTH);
return panel;
}
private Component remoteButtons() {
JButton upload = new JButton("Upload");
upload.addActionListener(a -> uploadIcon());
deleteButton.setEnabled(false);
deleteButton.addActionListener(a -> deleteSelectedIcon());
JPanel row = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 4, 0));
row.add(upload);
row.add(deleteButton);
return row;
}
private Component localFooter() {
com.ts3client.gfx.IconPack active = IconTheme.get().activePack();
JLabel pack = new JLabel("Icon Pack: " + (active == null ? "none" : active.name()));
pack.setBorder(BorderFactory.createEmptyBorder(0, 4, 0, 4));
return pack;
}
private JPanel buttons() {
selectButton.setEnabled(false);
selectButton.addActionListener(a -> choose(selectedIcon()));
JButton cancel = new JButton("Cancel");
cancel.addActionListener(a -> dispose());
JPanel panel = new JPanel(new BorderLayout());
JPanel right = new JPanel();
right.add(selectButton);
right.add(cancel);
panel.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(selectButton);
return panel;
}
private void choose(long iconId) {
if (iconId == 0) return;
chosenIconId = iconId;
accepted = true;
dispose();
}
private void loadRemoteIcons() {
status.setText("Loading icons…");
conn.requestServerIcons((ids, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) {
status.setText("Could not list the server's icons: " + error);
return;
}
status.setText(" ");
remoteModel.clear();
for (Long id : ids) remoteModel.addElement(id);
}));
}
private void uploadIcon() {
JFileChooser chooser = new JFileChooser();
chooser.setDialogTitle("Select an image to upload");
chooser.setFileFilter(new FileNameExtensionFilter("Images", "png", "jpg", "jpeg", "gif", "bmp", "svg"));
if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return;
File file = chooser.getSelectedFile();
status.setText("Uploading " + file.getName() + "");
conn.uploadIcon(file, (iconId, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) {
status.setText(" ");
JOptionPane.showMessageDialog(this, "Error uploading icon: " + error,
"Error", JOptionPane.ERROR_MESSAGE);
return;
}
loadRemoteIcons();
startRefreshing();
}));
}
private void deleteSelectedIcon() {
Long id = remoteList.getSelectedValue();
if (id == null) return;
int answer = JOptionPane.showConfirmDialog(this,
"Permanently delete this icon from the server?", "Confirmation",
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
if (answer != JOptionPane.YES_OPTION) return;
conn.deleteIcon(id, error -> SwingUtilities.invokeLater(() -> {
if (error != null) {
JOptionPane.showMessageDialog(this, "Failed to delete remote icon file: " + error,
"Error", JOptionPane.ERROR_MESSAGE);
return;
}
remoteModel.removeElement(id);
}));
}
/**
* Repaints while the repository is still fetching icons; it hands them over silently,
* so the only way to show them as they land is to keep asking.
*/
private void startRefreshing() {
Timer timer = new Timer(REFRESH_MS, null);
long deadline = System.currentTimeMillis() + REFRESH_TIMEOUT_MS;
timer.addActionListener(e -> {
remoteList.repaint();
localList.repaint();
if (!isDisplayable() || System.currentTimeMillis() > deadline || allIconsLoaded()) timer.stop();
});
timer.start();
}
private boolean allIconsLoaded() {
for (int i = 0; i < remoteModel.size(); i++) {
if (groupIcons.icon(remoteModel.get(i)) == null) return false;
}
return true;
}
/** Draws one icon, blank until the repository has it. */
private final class IconCellRenderer extends javax.swing.DefaultListCellRenderer {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focused) {
super.getListCellRendererComponent(list, "", index, selected, focused);
long id = value instanceof Long ? (Long) value : 0;
ImageIcon icon = groupIcons.icon(id);
setIcon(icon);
setHorizontalAlignment(CENTER);
setToolTipText("Icon " + id);
return this;
}
}
/** Convenience for callers that only need the picked id. */
static long pick(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, long current) {
IconChooserDialog dialog = new IconChooserDialog(owner, conn, groupIcons);
dialog.setVisible(true);
return dialog.isAccepted() ? dialog.getIconId() : current;
}
}

View File

@@ -0,0 +1,167 @@
package com.ts3client.ui;
import com.github.weisj.jsvg.SVGDocument;
import com.github.weisj.jsvg.parser.LoaderContext;
import com.github.weisj.jsvg.parser.SVGLoader;
import com.github.weisj.jsvg.view.ViewBox;
import com.ts3client.config.Settings;
import com.ts3client.gfx.IconPack;
import com.ts3client.gfx.IconPacks;
import javax.imageio.ImageIO;
import javax.swing.ImageIcon;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* The icons the user interface draws itself with, taken from the active
* {@link IconPack}.
*
* <p>Packs are TeamSpeak's, so an installed client's {@code gfx/} folder (and the
* packs from its add-on site) can be used as they are. A pack's own
* {@code FALLBACK} option decides whether icons it does not define are looked up
* in {@code default.zip}; whatever is still missing is drawn by {@link Icons}
* instead, so the client always has a complete set.
*
* <p>Rendering is cached per key and size: vector art is rasterised at the size it
* is asked for, bitmaps are scaled from the closest resolution the pack ships.
*/
public final class IconTheme {
/** The size of a tree row / menu item icon, as in the official client. */
public static final int SIZE = 16;
/** The size of the toolbar's buttons. */
public static final int TOOLBAR_SIZE = 24;
private static final IconTheme INSTANCE = new IconTheme();
private final Map<String, ImageIcon> cache = new ConcurrentHashMap<>();
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
private volatile List<IconPack> packs = List.of();
private volatile IconPack active;
private volatile IconPack fallback;
private IconTheme() {
}
public static IconTheme get() {
return INSTANCE;
}
/** The packs found on this machine, in display order. */
public List<IconPack> packs() {
return packs;
}
public IconPack activePack() {
return active;
}
/** Re-scans the pack folders and activates the one the settings name. */
public void reload(Settings settings) {
packs = IconPacks.findAll(new File(Settings.configDir(), "gfx"), settings.iconPackDir);
fallback = IconPacks.fallback(packs);
setPack(IconPacks.select(packs, settings.iconPack));
}
/** Switches to a pack (which may be {@code null} for the built-in icons). */
public void setPack(IconPack pack) {
if (active == pack) return;
active = pack;
cache.clear();
for (Runnable listener : listeners) listener.run();
}
/** Registers a repaint to run whenever the icons change. */
public void addListener(Runnable listener) {
listeners.add(listener);
}
/** @return the icon for a TeamSpeak icon key at 16&times;16, or {@code null} if no pack has it */
public static ImageIcon icon(String key) {
return icon(key, SIZE);
}
/** @return the icon for a TeamSpeak icon key, or {@code null} if no pack has it */
public static ImageIcon icon(String key, int size) {
return INSTANCE.lookup(key, size);
}
private ImageIcon lookup(String key, int size) {
if (key == null) return null;
String cacheKey = key + '@' + size;
ImageIcon cached = cache.get(cacheKey);
if (cached != null) return cached;
ImageIcon icon = render(active, key, size);
if (icon == null && active != null && active.fallsBack() && fallback != active) {
icon = render(fallback, key, size);
}
if (icon != null) cache.put(cacheKey, icon);
return icon;
}
private static ImageIcon render(IconPack pack, String key, int size) {
if (pack == null) return null;
byte[] data = pack.icon(key, size);
if (data == null || data.length == 0) return null;
BufferedImage image = pack.isVector(key, size) ? rasterize(data, size) : decode(data, size);
return image == null ? null : new ImageIcon(image);
}
private static BufferedImage rasterize(byte[] data, int size) {
try {
SVGDocument document = new SVGLoader().load(
new ByteArrayInputStream(data), null, LoaderContext.createDefault());
if (document == null) return null;
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = image.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
document.render(null, g, new ViewBox(size, size));
} finally {
g.dispose();
}
return image;
} catch (Exception e) {
return null;
}
}
private static BufferedImage decode(byte[] data, int size) {
try {
BufferedImage source = ImageIO.read(new ByteArrayInputStream(data));
if (source == null) return null;
if (source.getWidth() == size && source.getHeight() == size) return source;
BufferedImage scaled = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = scaled.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
// Keep the aspect ratio of packs whose art is not square.
double factor = Math.min((double) size / source.getWidth(), (double) size / source.getHeight());
int w = Math.max(1, (int) Math.round(source.getWidth() * factor));
int h = Math.max(1, (int) Math.round(source.getHeight() * factor));
g.drawImage(source.getScaledInstance(w, h, Image.SCALE_SMOOTH),
(size - w) / 2, (size - h) / 2, null);
} finally {
g.dispose();
}
return scaled;
} catch (Exception e) {
return null;
}
}
}

View File

@@ -8,13 +8,17 @@ import java.awt.RenderingHints;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
/** /**
* Programmatically drawn vector icons (no external image assets), so the client * The user interface's icons, taken from the active {@link IconTheme} pack and
* is fully self-contained. All icons are rendered at 16&times;16 with a small * named by TeamSpeak's icon keys.
* cache. *
* <p>The icons the client cannot do without are also drawn programmatically here,
* so a machine with no icon pack installed still gets a complete (if plain) set.
* Everything else — menu and dialog decoration — simply stays blank without a pack,
* which is why {@link #of(String)} may return {@code null}.
*/ */
public final class Icons { public final class Icons {
private static final int SZ = 16; private static final int SZ = IconTheme.SIZE;
private Icons() { private Icons() {
} }
@@ -23,11 +27,60 @@ public final class Icons {
void paint(Graphics2D g); void paint(Graphics2D g);
} }
/** @return the pack's icon for a TeamSpeak icon key, or {@code null} when it has none */
public static ImageIcon of(String key) {
return IconTheme.icon(key);
}
/**
* @return the pack's icon for the first key it has, or {@code null} when it has none
* of them — packs differ in which variants they ship
*/
public static ImageIcon ofAny(String... keys) {
for (String key : keys) {
ImageIcon icon = IconTheme.icon(key);
if (icon != null) return icon;
}
return null;
}
/** @return the pack's icon at a given size, or {@code null} when it has none */
public static ImageIcon of(String key, int size) {
return IconTheme.icon(key, size);
}
private static ImageIcon themed(String key, Painter fallback) {
return themed(key, SZ, fallback);
}
private static ImageIcon themed(String key, int size, Painter fallback) {
ImageIcon icon = IconTheme.icon(key, size);
return icon != null ? icon : make(size, fallback);
}
/**
* Like {@link #themed(String, Painter)}, but tries the keys in turn — packs need not
* ship every variant (none of TeamSpeak's own has a subscribed password channel).
*/
private static ImageIcon themed(String[] keys, Painter fallback) {
for (String key : keys) {
ImageIcon icon = IconTheme.icon(key, SZ);
if (icon != null) return icon;
}
return make(fallback);
}
private static ImageIcon make(Painter p) { private static ImageIcon make(Painter p) {
BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB); return make(SZ, p);
}
/** Runs a painter (which draws in a 16&times;16 box) scaled to {@code size}. */
private static ImageIcon make(int size, Painter p) {
BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics(); Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
g.scale(size / (double) SZ, size / (double) SZ);
p.paint(g); p.paint(g);
g.dispose(); g.dispose();
return new ImageIcon(img); return new ImageIcon(img);
@@ -36,174 +89,373 @@ public final class Icons {
// ---- tree icons ---- // ---- tree icons ----
public static ImageIcon server() { public static ImageIcon server() {
return make(g -> { return themed("SERVER_GREEN", Icons::paintServer);
g.setColor(new Color(0x2C6EA5));
g.fillRoundRect(2, 3, 12, 4, 2, 2);
g.fillRoundRect(2, 9, 12, 4, 2, 2);
g.setColor(new Color(0x9FD0F0));
g.fillOval(4, 4, 2, 2);
g.fillOval(4, 10, 2, 2);
});
} }
public static ImageIcon channel() { public static ImageIcon channel(boolean subscribed) {
return channelPainted(new Color(0x3E7CB1), false); return themed(keys("CHANNEL_GREEN", subscribed),
g -> paintChannel(g, new Color(0x3E7CB1), false, subscribed));
} }
public static ImageIcon channelLocked() { /** A channel nobody can join without its password. */
return channelPainted(new Color(0x8A6D3B), true); public static ImageIcon channelLocked(boolean subscribed) {
return themed(keys("CHANNEL_PRIVATE", subscribed),
g -> paintChannel(g, new Color(0x8A6D3B), true, subscribed));
} }
private static ImageIcon channelPainted(Color c, boolean lock) { /** A channel that has reached its client limit. */
return make(g -> { public static ImageIcon channelFull(boolean subscribed) {
g.setColor(c); return themed(keys("CHANNEL_RED", subscribed),
g.setStroke(new BasicStroke(1.6f)); g -> paintChannel(g, new Color(0xA53F3F), false, subscribed));
// simple speaker-cone glyph }
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5}; /** The pack keys to try for a channel icon, most specific first. */
int[] ys = {6, 3, 13, 10}; private static String[] keys(String base, boolean subscribed) {
g.fillPolygon(xs, ys, 4); return subscribed ? new String[]{base + "_SUBSCRIBED", base} : new String[]{base};
g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120);
if (lock) {
g.setColor(new Color(0xB8860B));
g.fillRoundRect(10, 9, 5, 5, 1, 1);
g.setColor(Color.WHITE);
g.fillRect(12, 10, 1, 2);
}
});
} }
// ---- client status icons ---- // ---- client status icons ----
public static ImageIcon clientIdle() { public static ImageIcon clientIdle() {
return person(Theme.IDLE_CLIENT); return clientIdle(SZ);
}
public static ImageIcon clientIdle(int size) {
return themed("PLAYER_OFF", size, g -> paintPerson(g, Theme.idleClient()));
} }
public static ImageIcon clientTalking() { public static ImageIcon clientTalking() {
return person(Theme.TALKING); return clientTalking(SZ);
}
public static ImageIcon clientTalking(int size) {
return themed("PLAYER_ON", size, g -> paintPerson(g, Theme.talking()));
} }
public static ImageIcon clientAway() { public static ImageIcon clientAway() {
return person(Theme.AWAY); return clientAway(SZ);
} }
private static ImageIcon person(Color c) { public static ImageIcon clientAway(int size) {
return make(g -> { return themed("AWAY", size, g -> paintPerson(g, Theme.away()));
g.setColor(c); }
g.fillOval(5, 2, 6, 6); // head
g.fillRoundRect(3, 9, 10, 6, 4, 4); // shoulders /** A channel commander that is not talking. */
}); public static ImageIcon clientCommander() {
return clientCommander(SZ);
}
/** A channel commander that is talking. */
public static ImageIcon clientCommanderTalking() {
return clientCommanderTalking(SZ);
}
public static ImageIcon clientQuery() {
return themed("SERVER_QUERY", g -> paintPerson(g, Theme.idleClient()));
} }
public static ImageIcon micMuted() { public static ImageIcon micMuted() {
return make(g -> { return micMuted(SZ);
g.setColor(Theme.MUTED); }
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f)); public static ImageIcon micMuted(int size) {
g.drawArc(4, 6, 8, 6, 200, 140); return themed("INPUT_MUTED", size, Icons::paintMicMuted);
g.drawLine(8, 12, 8, 14);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14); // slash
});
} }
public static ImageIcon speakerMuted() { public static ImageIcon speakerMuted() {
return make(g -> { return speakerMuted(SZ);
g.setColor(Theme.MUTED); }
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5}; public static ImageIcon speakerMuted(int size) {
int[] ys = {6, 3, 13, 10}; return themed("OUTPUT_MUTED", size, Icons::paintSpeakerMuted);
g.fillPolygon(xs, ys, 4); }
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14); /** The capture device is unavailable (another tab holds it), as opposed to muted. */
}); public static ImageIcon micDisabled() {
return micDisabled(SZ);
}
public static ImageIcon micDisabled(int size) {
return themed("HARDWARE_INPUT_MUTED", size, Icons::paintMicDisabled);
}
/** The playback device is unavailable, as opposed to muted/deafened. */
public static ImageIcon speakerDisabled() {
return speakerDisabled(SZ);
}
public static ImageIcon speakerDisabled(int size) {
return themed("HARDWARE_OUTPUT_MUTED", size, Icons::paintSpeakerDisabled);
}
/** TS3's "Local Mic Mute": silenced, but not reported as muted. */
public static ImageIcon micLocalMuted() {
return micLocalMuted(SZ);
}
public static ImageIcon micLocalMuted(int size) {
return themed("INPUT_MUTED_LOCAL", size, Icons::paintMicLocalMuted);
}
/** A channel commander that is not talking. */
public static ImageIcon clientCommander(int size) {
return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.idleClient()));
}
/** A channel commander that is talking. */
public static ImageIcon clientCommanderTalking(int size) {
return themed("PLAYER_COMMANDER_ON", size, g -> paintPerson(g, Theme.talking()));
} }
// ---- toolbar / action icons ---- // ---- toolbar / action icons ----
public static ImageIcon connect() { public static ImageIcon connect() {
return make(g -> { return themed("CONNECT", IconTheme.TOOLBAR_SIZE, Icons::paintConnect);
g.setColor(new Color(0x2E8B57));
g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2);
g.drawLine(10, 3, 10, 5);
g.drawLine(12, 3, 12, 5);
});
} }
public static ImageIcon disconnect() { public static ImageIcon disconnect() {
return make(g -> { return themed("DISCONNECT", IconTheme.TOOLBAR_SIZE, Icons::paintDisconnect);
g.setColor(Theme.MUTED);
g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2);
g.drawLine(2, 3, 6, 13);
});
} }
public static ImageIcon mic() { public static ImageIcon mic() {
return make(g -> { return themed("CAPTURE", IconTheme.TOOLBAR_SIZE, Icons::paintMic);
g.setColor(new Color(0x37474F));
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14);
g.drawLine(6, 14, 10, 14);
});
} }
public static ImageIcon speaker() { public static ImageIcon speaker() {
return make(g -> { return themed("PLAYBACK", IconTheme.TOOLBAR_SIZE, Icons::paintSpeaker);
g.setColor(new Color(0x37474F)); }
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5}; public static ImageIcon micMutedLarge() {
int[] ys = {6, 3, 13, 10}; return themed("INPUT_MUTED", IconTheme.TOOLBAR_SIZE, Icons::paintMicMuted);
g.fillPolygon(xs, ys, 4); }
g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120); public static ImageIcon speakerMutedLarge() {
}); return themed("OUTPUT_MUTED", IconTheme.TOOLBAR_SIZE, Icons::paintSpeakerMuted);
} }
/** Marks the server that currently owns the microphone. */ /** Marks the server that currently owns the microphone. */
public static ImageIcon micActive() { public static ImageIcon micActive() {
return make(g -> { return themed("ACTIVATE_MICROPHONE", IconTheme.TOOLBAR_SIZE, Icons::paintMicActive);
g.setColor(Theme.TALKING); }
g.fillOval(1, 1, 14, 14);
g.setColor(Color.WHITE); /** The same marker at tab-label size. */
g.fillRoundRect(6, 3, 4, 6, 2, 2); public static ImageIcon micActiveSmall() {
g.setStroke(new BasicStroke(1.4f)); return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive);
g.drawArc(4, 6, 8, 6, 200, 140); }
g.drawLine(8, 11, 8, 13);
}); /**
* A fixed-width "channel icon / edit icon" label icon, used where a tab must not
* change width as the object it shows changes (e.g. the info-panel-in-chat tab).
*/
public static ImageIcon channelClientPair() {
ImageIcon channel = channel(true);
ImageIcon edit = themed("EDIT", Icons::paintEdit);
int gap = 8;
int width = channel.getIconWidth() + gap + edit.getIconWidth();
int height = Math.max(channel.getIconHeight(), edit.getIconHeight());
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.drawImage(channel.getImage(), 0, (height - channel.getIconHeight()) / 2, null);
g.setColor(Theme.idleClient());
g.setFont(Theme.uiFont());
java.awt.FontMetrics fm = g.getFontMetrics();
int slashX = channel.getIconWidth() + (gap - fm.stringWidth("/")) / 2;
g.drawString("/", slashX, (height + fm.getAscent()) / 2 - 1);
g.drawImage(edit.getImage(), channel.getIconWidth() + gap,
(height - edit.getIconHeight()) / 2, null);
g.dispose();
return new ImageIcon(img);
}
/** The toolbar's away marker. */
public static ImageIcon away() {
return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.away()));
} }
public static ImageIcon settings() { public static ImageIcon settings() {
return make(g -> { return themed("SETTINGS", IconTheme.TOOLBAR_SIZE, Icons::paintSettings);
g.setColor(new Color(0x37474F));
g.setStroke(new BasicStroke(2f));
g.drawOval(5, 5, 6, 6);
for (int a = 0; a < 360; a += 45) {
double r = Math.toRadians(a);
int x1 = (int) (8 + Math.cos(r) * 5);
int y1 = (int) (8 + Math.sin(r) * 5);
int x2 = (int) (8 + Math.cos(r) * 7);
int y2 = (int) (8 + Math.sin(r) * 7);
g.drawLine(x1, y1, x2, y2);
}
});
} }
public static ImageIcon app() { public static ImageIcon app() {
return make(g -> { return app(SZ);
g.setColor(Theme.ACCENT); }
g.fillRoundRect(1, 1, 14, 14, 4, 4);
public static ImageIcon app(int size) {
return make(size, Icons::paintApp);
}
// ---- built-in painters ----
private static void paintServer(Graphics2D g) {
g.setColor(new Color(0x2C6EA5));
g.fillRoundRect(2, 3, 12, 4, 2, 2);
g.fillRoundRect(2, 9, 12, 4, 2, 2);
g.setColor(new Color(0x9FD0F0));
g.fillOval(4, 4, 2, 2);
g.fillOval(4, 10, 2, 2);
}
/**
* The speaker-cone glyph. An unsubscribed channel is drawn hollow and without the
* sound waves, so the two states stay apart even without an icon pack.
*/
private static void paintChannel(Graphics2D g, Color c, boolean lock, boolean subscribed) {
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.setColor(c);
g.setStroke(new BasicStroke(subscribed ? 1.6f : 1.2f));
if (subscribed) {
g.fillRect(2, 6, 3, 4);
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120);
} else {
g.drawRect(2, 6, 3, 4);
g.drawPolygon(xs, ys, 4);
}
if (lock) {
g.setColor(new Color(0xB8860B));
g.fillRoundRect(10, 9, 5, 5, 1, 1);
g.setColor(Color.WHITE); g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(1.6f)); g.fillRect(12, 10, 1, 2);
g.drawArc(4, 5, 8, 8, 30, 120); }
g.drawArc(2, 3, 12, 12, 30, 120); }
g.fillOval(7, 9, 2, 2);
}); private static void paintPerson(Graphics2D g, Color c) {
g.setColor(c);
g.fillOval(5, 2, 6, 6); // head
g.fillRoundRect(3, 9, 10, 6, 4, 4); // shoulders
}
private static void paintMicMuted(Graphics2D g) {
g.setColor(Theme.muted());
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14); // slash
}
private static void paintSpeakerMuted(Graphics2D g) {
g.setColor(Theme.muted());
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14);
}
/** Same shape as {@link #paintMicMuted}, but grey: unavailable, not muted by choice. */
private static void paintMicDisabled(Graphics2D g) {
g.setColor(Theme.idleClient());
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14);
}
/** Same shape as {@link #paintSpeakerMuted}, but grey: unavailable, not muted by choice. */
private static void paintSpeakerDisabled(Graphics2D g) {
g.setColor(Theme.idleClient());
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14);
}
/** {@link #paintMicMuted}, marked with a small dot: silenced, but not reported as muted. */
private static void paintMicLocalMuted(Graphics2D g) {
paintMicMuted(g);
g.setColor(Theme.accent());
g.fillOval(11, 10, 4, 4);
}
private static void paintConnect(Graphics2D g) {
g.setColor(new Color(0x2E8B57));
g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2);
g.drawLine(10, 3, 10, 5);
g.drawLine(12, 3, 12, 5);
}
private static void paintDisconnect(Graphics2D g) {
g.setColor(Theme.muted());
g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2);
g.drawLine(2, 3, 6, 13);
}
private static void paintMic(Graphics2D g) {
g.setColor(new Color(0x37474F));
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14);
g.drawLine(6, 14, 10, 14);
}
private static void paintSpeaker(Graphics2D g) {
g.setColor(new Color(0x37474F));
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120);
}
private static void paintMicActive(Graphics2D g) {
g.setColor(Theme.talking());
g.fillOval(1, 1, 14, 14);
g.setColor(Color.WHITE);
g.fillRoundRect(6, 3, 4, 6, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 11, 8, 13);
}
/** A pencil glyph, drawn when no icon pack ships an "EDIT" icon. */
private static void paintEdit(Graphics2D g) {
g.setColor(new Color(0x37474F));
g.setStroke(new BasicStroke(1.4f));
g.drawLine(3, 13, 11, 5);
g.drawLine(3, 13, 4, 10);
g.drawLine(4, 10, 11, 3);
g.drawLine(11, 3, 13, 5);
g.drawLine(13, 5, 11, 7);
g.drawLine(11, 5, 13, 7);
}
private static void paintSettings(Graphics2D g) {
g.setColor(new Color(0x37474F));
g.setStroke(new BasicStroke(2f));
g.drawOval(5, 5, 6, 6);
for (int a = 0; a < 360; a += 45) {
double r = Math.toRadians(a);
int x1 = (int) (8 + Math.cos(r) * 5);
int y1 = (int) (8 + Math.sin(r) * 5);
int x2 = (int) (8 + Math.cos(r) * 7);
int y2 = (int) (8 + Math.sin(r) * 7);
g.drawLine(x1, y1, x2, y2);
}
}
private static void paintApp(Graphics2D g) {
g.setColor(Theme.accent());
g.fillRoundRect(1, 1, 14, 14, 4, 4);
g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(1.6f));
g.drawArc(4, 5, 8, 8, 30, 120);
g.drawArc(2, 3, 12, 12, 30, 120);
g.fillOval(7, 9, 2, 2);
} }
} }

View File

@@ -95,6 +95,7 @@ public final class IdentitiesDialog extends JDialog {
reload(); reload();
if (!listModel.isEmpty()) list.setSelectedIndex(0); if (!listModel.isEmpty()) list.setSelectedIndex(0);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE); setDefaultCloseOperation(DISPOSE_ON_CLOSE);
pack(); pack();
setMinimumSize(new Dimension(640, 320)); setMinimumSize(new Dimension(640, 320));

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