Show servers' icons on bookmarks, and their host button in the toolbar's corner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 22:28:35 +00:00
parent cf1db61a4a
commit 2c1b37ee30
20 changed files with 419 additions and 30 deletions

View File

@@ -354,6 +354,7 @@ class ServerController internal constructor(
selfClientId = if (session.isConnected) conn.selfClientId else 0,
channelId = if (session.isConnected) self?.channelId ?: 0 else 0,
inputMode = app.settings.inputMode,
hostButton = if (session.isConnected) conn.model.hostButton() else null,
)
}
@@ -412,7 +413,15 @@ class ServerController internal constructor(
refresh()
}
override fun onModelChanged() = scheduleTree(structure = true)
override fun onModelChanged() {
scheduleTree(structure = true)
// A server edit may have changed it.
if (session.isConnected && conn.model.hostButton() != _state.value.hostButton) refresh()
}
override fun onServerIconChanged(iconId: Long) =
app.serverIconChanged(session.address(), session.port(), iconId)
override fun onInfoUpdated() {
_infoUpdates.update { it + 1 }
// Avatars announce their arrival as info updates.

View File

@@ -94,7 +94,7 @@ data class ServerTree(
countryFlags: Boolean): ServerTree =
ServerTree(
roots.map { channel(it, model, contacts, colorContacts) },
model.serverProperty("virtualserver_icon_id").toLongOrNull() ?: 0,
model.serverIconId(),
countryFlags,
)

View File

@@ -18,6 +18,7 @@ import com.ts3client.config.Settings
import com.ts3client.contacts.Contact
import com.ts3client.contacts.ContactStore
import com.ts3client.myts.MyTeamSpeak
import com.ts3client.net.HostButton
import com.ts3client.myts.MyTeamSpeakLogin
import com.ts3client.session.Sessions
import com.ts3client.sound.SoundNotifier
@@ -51,6 +52,7 @@ data class SessionState(
val selfClientId: Int = 0,
val channelId: Int = 0,
val inputMode: Settings.InputMode = Settings.InputMode.VOICE_ACTIVATION,
val hostButton: HostButton? = null,
)
/** Something that happened once, for the UI to tell the user about. */
@@ -270,6 +272,22 @@ class SessionController(private val context: Context) {
saveBookmarks()
}
/** Bookmarks leading to [address]:[port] take on the icon the server shows now. */
internal fun serverIconChanged(address: String, port: Int, iconId: Long) {
main.post {
val all = bookmarkStore.all()
var changed = false
for (i in all.indices) {
val b = all[i]
if (!b.pointsAt(address, port) || b.iconId == iconId) continue
// A new entry, so the list the UI holds changes along with it.
all[i] = b.copy().also { it.iconId = iconId }
changed = true
}
if (changed) saveBookmarks()
}
}
private fun saveBookmarks() {
bookmarkStore.save()
_bookmarks.value = bookmarkStore.all().toList()

View File

@@ -1,6 +1,8 @@
package com.ts3client.android.ui
import android.graphics.BitmapFactory
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
@@ -44,6 +47,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@@ -51,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.IdentityInfo
import com.ts3client.android.SessionController
import com.ts3client.config.Bookmark
import com.ts3client.net.IconRepository
/** Where to connect to: an address typed in, or a bookmark. */
@OptIn(ExperimentalMaterial3Api::class)
@@ -205,6 +210,16 @@ private fun IdentityDialog(identities: List<IdentityInfo>, selected: String, onD
)
}
/** The server's icon as last seen there, else the pack's bookmark icon. */
@Composable
private fun BookmarkIcon(iconId: Long) {
val cached = remember(iconId) {
IconRepository.cached(iconId)?.let { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }
}
if (cached != null) Image(cached, null, Modifier.size(24.dp))
else PackIcon("BOOKMARK_MANAGER", null)
}
@Composable
private fun BookmarkRow(
bookmark: Bookmark,
@@ -220,7 +235,7 @@ private fun BookmarkRow(
supportingContent = {
Text("${bookmark.address}:${bookmark.port}" + (identity?.let { " · ${it.name}" } ?: ""))
},
leadingContent = { PackIcon("BOOKMARK_MANAGER", null) },
leadingContent = { BookmarkIcon(bookmark.iconId) },
trailingContent = {
Box {
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "Bookmark options") }

View File

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

View File

@@ -231,6 +231,7 @@ fun ServerScreen(
title = { ServerSwitcher(vm, server, state, onConnectOther) },
expandedHeight = 48.dp,
actions = {
state.hostButton?.let { HostButtonAction(it) { url -> runCatching { uriHandler.openUri(url) } } }
IconButton(onClick = find::show) { PackIcon("FIND", "Find") }
IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") }
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }

View File

@@ -18,6 +18,8 @@ public final class Bookmark {
public String channelPassword = "";
/** Connect to this server automatically when the client starts. */
public boolean connectOnStartup = false;
/** The server's icon as last seen there, 0 for none or not yet known. */
public long iconId = 0;
public Bookmark() {
}
@@ -37,9 +39,15 @@ public final class Bookmark {
b.channel = channel;
b.channelPassword = channelPassword;
b.connectOnStartup = connectOnStartup;
b.iconId = iconId;
return b;
}
/** Whether this bookmark leads to the server at {@code address}:{@code port}. */
public boolean pointsAt(String address, int port) {
return this.port == port && this.address != null && this.address.trim().equalsIgnoreCase(address.trim());
}
/** Display name, falling back to "address:port" when no label is set. */
public String displayName() {
if (label != null && !label.isBlank()) return label;

View File

@@ -30,6 +30,23 @@ public final class Bookmarks {
if (index >= 0 && index < entries.size()) entries.remove(index);
}
/**
* Takes on the icon a server shows now for every bookmark leading to it.
*
* @return whether any bookmark changed, and so needs saving and redrawing
*/
public boolean rememberServerIcon(String address, int port, long iconId) {
if (address == null || address.isBlank()) return false;
boolean changed = false;
for (Bookmark b : entries) {
if (b.pointsAt(address, port) && b.iconId != iconId) {
b.iconId = iconId;
changed = true;
}
}
return changed;
}
/** Bookmarks flagged to connect at startup, each in its own tab. */
public List<Bookmark> startupBookmarks() {
List<Bookmark> out = new ArrayList<>();
@@ -61,6 +78,7 @@ public final class Bookmarks {
bm.channel = p.getProperty(prefix + "channel", "");
bm.channelPassword = p.getProperty(prefix + "channelPassword", "");
bm.connectOnStartup = Boolean.parseBoolean(p.getProperty(prefix + "connectOnStartup", "false"));
bm.iconId = parseLong(p.getProperty(prefix + "iconId"));
if (bm.address != null && !bm.address.isBlank()) b.entries.add(bm);
}
return b;
@@ -81,6 +99,7 @@ public final class Bookmarks {
p.setProperty(prefix + "channel", nullToEmpty(bm.channel));
p.setProperty(prefix + "channelPassword", nullToEmpty(bm.channelPassword));
p.setProperty(prefix + "connectOnStartup", Boolean.toString(bm.connectOnStartup));
p.setProperty(prefix + "iconId", Long.toString(bm.iconId));
}
try {
AppDirs.createProfile();
@@ -98,6 +117,15 @@ public final class Bookmarks {
}
}
private static long parseLong(String v) {
if (v == null) return 0;
try {
return Long.parseLong(v.trim());
} catch (NumberFormatException e) {
return 0;
}
}
private static String nullToEmpty(String s) {
return s == null ? "" : s;
}

View File

@@ -0,0 +1,19 @@
package com.ts3client.net;
/**
* The button a server puts in the toolbar's top right corner, linking to its host's site.
*
* @param url what a click opens; empty when the button only shows
* @param gfxUrl the picture to show; empty for TeamSpeak's own host button icon
* @param tooltip shown on hover; may be empty
*/
public record HostButton(String url, String gfxUrl, String tooltip) {
/** @return the button, or {@code null} when the server sets neither a link nor a picture */
public static HostButton of(String url, String gfxUrl, String tooltip) {
url = url == null ? "" : url.trim();
gfxUrl = gfxUrl == null ? "" : gfxUrl.trim();
if (url.isEmpty() && gfxUrl.isEmpty()) return null;
return new HostButton(url, gfxUrl, tooltip == null ? "" : tooltip.trim());
}
}

View File

@@ -43,7 +43,6 @@ public final class IconRepository {
private final Fetcher fetcher;
private final Runnable onIconLoaded;
private final File cacheDir = new File(AppDirs.profile(), "icons");
private final Map<Long, byte[]> icons = new ConcurrentHashMap<>();
private final Set<Long> pending = ConcurrentHashMap.newKeySet();
private final Set<Long> failed = ConcurrentHashMap.newKeySet();
@@ -101,11 +100,34 @@ public final class IconRepository {
long id = normalize(iconId);
byte[] data = get(id);
if (data == null) return null;
File file = new File(cacheDir, "icon_" + id + ".png");
File file = new File(cacheDir(), "icon_" + id + ".png");
if (!file.isFile()) writeDiskCache(id, data);
return file.isFile() ? file : null;
}
/**
* An icon that needs no server: one shipped with the client or downloaded earlier,
* e.g. for a bookmark while it is not connected.
*
* @return the icon's PNG bytes, or {@code null} if it has never been fetched
*/
public static byte[] cached(long iconId) {
long id = normalize(iconId);
if (id == 0) return null;
byte[] bundled = readBundled(id);
return bundled != null || id <= MAX_BUNDLED_ID ? bundled : readDiskCache(id);
}
/** An icon id as a property carries it, 0 for none or garbage. */
public static long parseId(String value) {
if (value == null || value.isBlank()) return 0;
try {
return normalize(Long.parseLong(value.trim()));
} catch (NumberFormatException e) {
return 0;
}
}
/** Forgets download failures so icons can be retried on the next reconnect. */
public void retryFailed() {
failed.clear();
@@ -152,8 +174,12 @@ public final class IconRepository {
}
}
private byte[] readDiskCache(long id) {
File file = new File(cacheDir, "icon_" + id + ".png");
private static File cacheDir() {
return new File(AppDirs.profile(), "icons");
}
private static byte[] readDiskCache(long id) {
File file = new File(cacheDir(), "icon_" + id + ".png");
if (!file.isFile()) return null;
try {
return Files.readAllBytes(file.toPath());
@@ -165,8 +191,8 @@ public final class IconRepository {
private void writeDiskCache(long id, byte[] data) {
try {
//noinspection ResultOfMethodCallIgnored
cacheDir.mkdirs();
Files.write(new File(cacheDir, "icon_" + id + ".png").toPath(), data);
cacheDir().mkdirs();
Files.write(new File(cacheDir(), "icon_" + id + ".png").toPath(), data);
} catch (IOException ignored) {
// the in-memory cache still serves this session
}

View File

@@ -297,6 +297,18 @@ public final class ServerModel {
return serverProperties.getOrDefault(key, "");
}
/** The server's own icon, 0 when it has none. */
public synchronized long serverIconId() {
return IconRepository.parseId(serverProperty("virtualserver_icon_id"));
}
/** The button the server puts in the toolbar's corner, or {@code null} when it has none. */
public synchronized HostButton hostButton() {
return HostButton.of(serverProperty("virtualserver_hostbutton_url"),
serverProperty("virtualserver_hostbutton_gfx_url"),
serverProperty("virtualserver_hostbutton_tooltip"));
}
public synchronized int defaultChannelGroupId() {
return defaultChannelGroupId;
}

View File

@@ -44,6 +44,13 @@ public final class ServerSession {
* @param peerName the recipient's display name for a private message, else empty
*/
void onOwnChat(ChatScope scope, int peerClientId, String peerName, String text);
/**
* The server's icon is known or changed (0: it has none), and is on its way to the
* icon cache, so bookmarks leading here can show it while not connected.
*/
default void onServerIconChanged(long iconId) {
}
}
private final Settings settings;
@@ -60,6 +67,8 @@ public final class ServerSession {
private volatile Target lastTarget;
/** The channel we were in when the connection last went, to return to on a reconnect. */
private volatile String lastChannelPath = "";
/** The server icon last reported through {@link Listener#onServerIconChanged}; -1 for none yet. */
private volatile long serverIconId = -1;
private record Target(String address, int port, String nickname, String password, String channelPassword) {
}
@@ -437,6 +446,15 @@ public final class ServerSession {
setStatus(message);
}
private void checkServerIcon() {
if (!conn.isConnected()) return;
long id = conn.getModel().serverIconId();
if (id == serverIconId) return;
serverIconId = id;
conn.getIcons().get(id);
listener.onServerIconChanged(id);
}
/** Applies what a connection event means for the session, then passes it on. */
private final class Events implements ConnectionListener {
@@ -452,11 +470,13 @@ public final class ServerSession {
listener.onConnected();
listener.onSystemMessage("Connected.");
listener.onSessionChanged();
checkServerIcon();
}
@Override
public void onDisconnected(String reason) {
connecting = false;
serverIconId = -1;
conn.getModel().clear();
listener.onDisconnected(reason);
listener.onSystemMessage("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
@@ -473,6 +493,7 @@ public final class ServerSession {
title = name;
listener.onSessionChanged();
}
checkServerIcon();
listener.onModelChanged();
}

View File

@@ -11,7 +11,9 @@ import com.ts3client.teamspeak.TeamSpeakSettingsDb;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JDialog;
@@ -25,6 +27,7 @@ import javax.swing.JSplitPane;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.GridBagConstraints;
@@ -75,6 +78,16 @@ public final class BookmarksDialog extends JDialog {
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
list.setVisibleRowCount(12);
list.setCellRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> l, Object value, int index,
boolean selected, boolean focused) {
super.getListCellRendererComponent(l, value, index, selected, focused);
ImageIcon icon = GroupIcons.cached(((Bookmark) value).iconId);
setIcon(icon != null ? icon : Icons.of("SERVER_GREEN"));
return this;
}
});
list.addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) onSelectionChanged();
});

View File

@@ -287,9 +287,7 @@ final class DropIndicatorTree extends JTree {
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);
List<Icon> icons = badgesOf((DefaultMutableTreeNode) getPathForRow(row).getLastPathComponent());
if (icons.isEmpty()) continue;
GroupIcons.Row strip = new GroupIcons.Row(icons);
@@ -299,7 +297,12 @@ final class DropIndicatorTree extends JTree {
}
/** The icon strip a row shows on its right, empty when it has none (yet). */
private List<Icon> badgesOf(Object node) {
private List<Icon> badgesOf(DefaultMutableTreeNode treeNode) {
Object node = treeNode.getUserObject();
if (treeNode.isRoot()) {
Icon icon = groupIcons.icon(model.serverIconId());
return icon == null ? List.of() : List.of(icon);
}
if (node instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) node;
List<Icon> icons = groupIcons.iconsOf(

View File

@@ -24,6 +24,8 @@ public final class GroupIcons {
/** Tree rows are 16&times;16 like TeamSpeak's own icon set. */
private static final int SIZE = 16;
private static final Map<Long, ImageIcon> DISK_CACHED = new ConcurrentHashMap<>();
private final IconRepository repository;
private final Map<Long, ImageIcon> decoded = new ConcurrentHashMap<>();
@@ -39,27 +41,52 @@ public final class GroupIcons {
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, UIScale.scale(SIZE));
ImageIcon themed = themed(iconId);
if (themed != null) return themed;
}
ImageIcon cached = decoded.get(iconId);
if (cached != null) return cached;
byte[] data = repository.get(iconId);
if (data == null) return null;
ImageIcon icon = decode(repository.get(iconId));
if (icon != null) decoded.put(iconId, icon);
return icon;
}
/**
* An icon from the client's own cache, without a server to fetch it from, as
* bookmarks show their server's.
*
* @return the icon, or {@code null} when it has never been downloaded
*/
public static ImageIcon cached(long iconId) {
if (iconId == 0) return null;
ImageIcon themed = themed(iconId);
if (themed != null) return themed;
ImageIcon icon = DISK_CACHED.get(iconId);
if (icon != null) return icon;
icon = decode(IconRepository.cached(iconId));
if (icon != null) DISK_CACHED.put(iconId, icon);
return icon;
}
/**
* 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.
*/
private static ImageIcon themed(long iconId) {
if (iconId <= 0 || iconId > IconRepository.MAX_BUNDLED_ID) return null;
return IconTheme.icon("GROUP_" + iconId, UIScale.scale(SIZE));
}
private static ImageIcon decode(byte[] data) {
if (data == null) return null;
ImageIcon icon = new ImageIcon(data);
if (icon.getIconWidth() <= 0) return null;
int size = UIScale.scale(SIZE);
if (icon.getIconWidth() != size || icon.getIconHeight() != size) {
icon = new ImageIcon(icon.getImage().getScaledInstance(size, size, Image.SCALE_SMOOTH));
}
decoded.put(iconId, icon);
return icon;
}

View File

@@ -0,0 +1,74 @@
package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.net.HostButton;
import com.ts3client.net.PictureDownloads;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.SwingUtilities;
import java.awt.Image;
import java.io.File;
import java.util.Objects;
/**
* The server's host button in the toolbar's corner: its own picture, fitted to the
* toolbar's height, or TeamSpeak's host button icon while there is none (yet).
*/
final class HostButtonView extends JButton {
/** Keeps a banner-shaped picture from crowding out the rest of the toolbar. */
private static final int MAX_ASPECT = 6;
private final PictureDownloads pictures;
private final Runnable onPictureLoaded = () -> SwingUtilities.invokeLater(this::refreshIcon);
private HostButton shown;
HostButtonView(PictureDownloads pictures) {
this.pictures = pictures;
setVisible(false);
addActionListener(e -> {
if (shown != null && !shown.url().isEmpty()) Links.open(shown.url(), this);
});
}
/** @param button the selected server's, or {@code null} to hide it */
void show(HostButton button) {
if (Objects.equals(button, shown)) return;
shown = button;
setVisible(button != null);
if (button == null) return;
setToolTipText(!button.tooltip().isEmpty() ? button.tooltip() : button.url().isEmpty() ? null : button.url());
refreshIcon();
}
@Override
public void addNotify() {
super.addNotify();
pictures.addListener(onPictureLoaded);
}
@Override
public void removeNotify() {
pictures.removeListener(onPictureLoaded);
super.removeNotify();
}
private void refreshIcon() {
if (shown == null) return;
ImageIcon picture = shown.gfxUrl().isEmpty() ? null : picture(pictures.get(shown.gfxUrl()));
setIcon(picture != null ? picture : Icons.of("HOSTER_BUTTON", UIScale.scale(IconTheme.TOOLBAR_SIZE)));
}
private static ImageIcon picture(File file) {
if (file == null) return null;
ImageIcon icon = new ImageIcon(file.getPath());
int w = icon.getIconWidth();
int h = icon.getIconHeight();
if (w <= 0 || h <= 0) return null;
int height = UIScale.scale(IconTheme.TOOLBAR_SIZE);
int width = Math.min(Math.max(1, Math.round((float) w * height / h)), height * MAX_ASPECT);
if (w == width && h == height) return icon;
return new ImageIcon(icon.getImage().getScaledInstance(width, height, Image.SCALE_SMOOTH));
}
}

View File

@@ -14,6 +14,7 @@ import com.ts3client.myts.MyTeamSpeakLogin;
import com.ts3client.teamspeak.TeamSpeakImporter;
import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.HostButton;
import com.ts3client.net.PictureDownloads;
import com.ts3client.session.SelfState;
import com.ts3client.session.ServerSession;
@@ -368,7 +369,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
revalidate();
repaint();
}
});
}, pictures);
}
/** Rebuilds the icon-bearing chrome after the active icon pack changed. */
@@ -460,6 +461,18 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
updateTray();
}
/** The server on {@code tab} may have changed its host button. */
void refreshHostButton(ServerTab tab) {
if (tab == selected) toolbar.showHostButton(hostButtonOf(tab));
}
/** Bookmarks leading to the server on {@code tab} take on its icon. */
void serverIconChanged(ServerTab tab, long iconId) {
if (!bookmarks.rememberServerIcon(tab.session().address(), tab.session().port(), iconId)) return;
bookmarks.save();
menuBar.rebuildBookmarks();
}
/** The local client on {@code tab} started or stopped talking, muted itself, … */
void selfStateChanged(ServerTab tab) {
if (tabs.contains(tab)) updateTray();
@@ -909,10 +922,15 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
boolean away = connected && selected.session().isAway();
boolean commander = connected && selected.session().isCommander();
toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away);
toolbar.showHostButton(hostButtonOf(selected));
menuBar.refresh(connected, away, commander);
updateTray();
}
private static HostButton hostButtonOf(ServerTab tab) {
return tab != null && tab.session().isConnected() ? tab.connection().getModel().hostButton() : null;
}
/**
* The server the tray icon speaks for: the one holding the microphone, or the
* visible one when nobody is capturing.

View File

@@ -3,6 +3,7 @@ package com.ts3client.ui;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import javax.swing.ImageIcon;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
@@ -111,6 +112,21 @@ final class MainMenuBar extends JMenuBar {
bookmarksMenu = new JMenu("Bookmarks");
rebuildBookmarks();
// A server's icon may have finished downloading since the menu was built.
bookmarksMenu.addMenuListener(new MenuListener() {
@Override
public void menuSelected(MenuEvent e) {
rebuildBookmarks();
}
@Override
public void menuDeselected(MenuEvent e) {
}
@Override
public void menuCanceled(MenuEvent e) {
}
});
JMenu self = new JMenu("Self");
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE"));
@@ -218,7 +234,8 @@ final class MainMenuBar extends JMenuBar {
void rebuildBookmarks() {
bookmarksMenu.removeAll();
for (Bookmark b : bookmarks.all()) {
JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN"));
ImageIcon serverIcon = GroupIcons.cached(b.iconId);
JMenuItem item = new JMenuItem(b.displayName(), serverIcon != null ? serverIcon : Icons.of("SERVER_GREEN"));
item.setToolTipText("Connect here (middle-click to open a new tab)");
item.addActionListener(e -> listener.onConnectBookmark(b, false));
item.addMouseListener(new MouseAdapter() {

View File

@@ -3,6 +3,8 @@ package com.ts3client.ui;
import com.formdev.flatlaf.util.ScaledEmptyBorder;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.config.Settings;
import com.ts3client.net.HostButton;
import com.ts3client.net.PictureDownloads;
import javax.swing.Box;
import javax.swing.JButton;
@@ -22,10 +24,10 @@ import java.awt.FlowLayout;
/**
* The main window's toolbar: connect/disconnect, the per-tab mic/speaker/away
* controls, and the Master Volume slider on the right. A right-click anywhere on
* the bar opens a customization menu for the window's optional chrome (this
* toolbar has no say over the status bar's own visibility beyond reporting the
* toggle).
* controls, and the Master Volume slider and the server's host button on the
* right. A right-click anywhere on the bar opens a customization menu for the
* window's optional chrome (this toolbar has no say over the status bar's own
* visibility beyond reporting the toggle).
*
* <p>Talks to {@link MainFrame} only through {@link Listener}, so it owns no
* connection/tab state itself &mdash; {@link #refresh} is handed everything it
@@ -68,8 +70,9 @@ final class MainToolbar extends JToolBar {
private final JToggleButton speakerButton;
private final DropDownToggleButton awayButton;
private final JComponent masterVolumePanel;
private final HostButtonView hostButton;
MainToolbar(Settings settings, Listener listener) {
MainToolbar(Settings settings, Listener listener, PictureDownloads pictures) {
this.settings = settings;
this.listener = listener;
@@ -120,6 +123,13 @@ final class MainToolbar extends JToolBar {
masterVolumePanel = buildMasterVolumeControl();
masterVolumePanel.setVisible(settings.showMasterVolumeSlider);
add(masterVolumePanel);
hostButton = new HostButtonView(pictures);
add(hostButton);
}
/** Shows the selected server's host button; {@code null} hides it. */
void showHostButton(HostButton button) {
hostButton.show(button);
}
/** Reflects the current tab's state on the buttons. */

View File

@@ -111,6 +111,7 @@ final class ServerTabConnectionEvents implements ServerSession.Listener {
treePanel.rebuild();
treeActions.renderInfo();
treeActions.refreshGroupsDialog();
host.refreshHostButton(tab);
if (session.isConnected()) {
ChannelNode channel = session.currentChannel();
chatPanel.setServerName(conn.getModel().getServerName());
@@ -119,6 +120,11 @@ final class ServerTabConnectionEvents implements ServerSession.Listener {
});
}
@Override
public void onServerIconChanged(long iconId) {
SwingUtilities.invokeLater(() -> host.serverIconChanged(tab, iconId));
}
@Override
public void onInfoUpdated() {
SwingUtilities.invokeLater(treeActions::renderInfo);