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, selfClientId = if (session.isConnected) conn.selfClientId else 0,
channelId = if (session.isConnected) self?.channelId ?: 0 else 0, channelId = if (session.isConnected) self?.channelId ?: 0 else 0,
inputMode = app.settings.inputMode, inputMode = app.settings.inputMode,
hostButton = if (session.isConnected) conn.model.hostButton() else null,
) )
} }
@@ -412,7 +413,15 @@ class ServerController internal constructor(
refresh() 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() { override fun onInfoUpdated() {
_infoUpdates.update { it + 1 } _infoUpdates.update { it + 1 }
// Avatars announce their arrival as info updates. // Avatars announce their arrival as info updates.

View File

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

View File

@@ -18,6 +18,7 @@ import com.ts3client.config.Settings
import com.ts3client.contacts.Contact import com.ts3client.contacts.Contact
import com.ts3client.contacts.ContactStore import com.ts3client.contacts.ContactStore
import com.ts3client.myts.MyTeamSpeak import com.ts3client.myts.MyTeamSpeak
import com.ts3client.net.HostButton
import com.ts3client.myts.MyTeamSpeakLogin import com.ts3client.myts.MyTeamSpeakLogin
import com.ts3client.session.Sessions import com.ts3client.session.Sessions
import com.ts3client.sound.SoundNotifier import com.ts3client.sound.SoundNotifier
@@ -51,6 +52,7 @@ data class SessionState(
val selfClientId: Int = 0, val selfClientId: Int = 0,
val channelId: Int = 0, val channelId: Int = 0,
val inputMode: Settings.InputMode = Settings.InputMode.VOICE_ACTIVATION, val inputMode: Settings.InputMode = Settings.InputMode.VOICE_ACTIVATION,
val hostButton: HostButton? = null,
) )
/** Something that happened once, for the UI to tell the user about. */ /** Something that happened once, for the UI to tell the user about. */
@@ -270,6 +272,22 @@ class SessionController(private val context: Context) {
saveBookmarks() 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() { private fun saveBookmarks() {
bookmarkStore.save() bookmarkStore.save()
_bookmarks.value = bookmarkStore.all().toList() _bookmarks.value = bookmarkStore.all().toList()

View File

@@ -1,6 +1,8 @@
package com.ts3client.android.ui package com.ts3client.android.ui
import android.graphics.BitmapFactory
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions 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.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -51,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.IdentityInfo import com.ts3client.android.IdentityInfo
import com.ts3client.android.SessionController import com.ts3client.android.SessionController
import com.ts3client.config.Bookmark import com.ts3client.config.Bookmark
import com.ts3client.net.IconRepository
/** Where to connect to: an address typed in, or a bookmark. */ /** Where to connect to: an address typed in, or a bookmark. */
@OptIn(ExperimentalMaterial3Api::class) @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 @Composable
private fun BookmarkRow( private fun BookmarkRow(
bookmark: Bookmark, bookmark: Bookmark,
@@ -220,7 +235,7 @@ private fun BookmarkRow(
supportingContent = { supportingContent = {
Text("${bookmark.address}:${bookmark.port}" + (identity?.let { " · ${it.name}" } ?: "")) Text("${bookmark.address}:${bookmark.port}" + (identity?.let { " · ${it.name}" } ?: ""))
}, },
leadingContent = { PackIcon("BOOKMARK_MANAGER", null) }, leadingContent = { BookmarkIcon(bookmark.iconId) },
trailingContent = { trailingContent = {
Box { Box {
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "Bookmark options") } 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) }, title = { ServerSwitcher(vm, server, state, onConnectOther) },
expandedHeight = 48.dp, expandedHeight = 48.dp,
actions = { actions = {
state.hostButton?.let { HostButtonAction(it) { url -> runCatching { uriHandler.openUri(url) } } }
IconButton(onClick = find::show) { PackIcon("FIND", "Find") } IconButton(onClick = find::show) { PackIcon("FIND", "Find") }
IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") } IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") }
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }

View File

@@ -18,6 +18,8 @@ public final class Bookmark {
public String channelPassword = ""; public String channelPassword = "";
/** Connect to this server automatically when the client starts. */ /** Connect to this server automatically when the client starts. */
public boolean connectOnStartup = false; 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() { public Bookmark() {
} }
@@ -37,9 +39,15 @@ public final class Bookmark {
b.channel = channel; b.channel = channel;
b.channelPassword = channelPassword; b.channelPassword = channelPassword;
b.connectOnStartup = connectOnStartup; b.connectOnStartup = connectOnStartup;
b.iconId = iconId;
return b; 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. */ /** Display name, falling back to "address:port" when no label is set. */
public String displayName() { public String displayName() {
if (label != null && !label.isBlank()) return label; 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); 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. */ /** Bookmarks flagged to connect at startup, each in its own tab. */
public List<Bookmark> startupBookmarks() { public List<Bookmark> startupBookmarks() {
List<Bookmark> out = new ArrayList<>(); List<Bookmark> out = new ArrayList<>();
@@ -61,6 +78,7 @@ public final class Bookmarks {
bm.channel = p.getProperty(prefix + "channel", ""); bm.channel = p.getProperty(prefix + "channel", "");
bm.channelPassword = p.getProperty(prefix + "channelPassword", ""); bm.channelPassword = p.getProperty(prefix + "channelPassword", "");
bm.connectOnStartup = Boolean.parseBoolean(p.getProperty(prefix + "connectOnStartup", "false")); 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); if (bm.address != null && !bm.address.isBlank()) b.entries.add(bm);
} }
return b; return b;
@@ -81,6 +99,7 @@ public final class Bookmarks {
p.setProperty(prefix + "channel", nullToEmpty(bm.channel)); p.setProperty(prefix + "channel", nullToEmpty(bm.channel));
p.setProperty(prefix + "channelPassword", nullToEmpty(bm.channelPassword)); p.setProperty(prefix + "channelPassword", nullToEmpty(bm.channelPassword));
p.setProperty(prefix + "connectOnStartup", Boolean.toString(bm.connectOnStartup)); p.setProperty(prefix + "connectOnStartup", Boolean.toString(bm.connectOnStartup));
p.setProperty(prefix + "iconId", Long.toString(bm.iconId));
} }
try { try {
AppDirs.createProfile(); 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) { private static String nullToEmpty(String s) {
return s == null ? "" : 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 Fetcher fetcher;
private final Runnable onIconLoaded; private final Runnable onIconLoaded;
private final File cacheDir = new File(AppDirs.profile(), "icons");
private final Map<Long, byte[]> icons = new ConcurrentHashMap<>(); private final Map<Long, byte[]> icons = new ConcurrentHashMap<>();
private final Set<Long> pending = ConcurrentHashMap.newKeySet(); private final Set<Long> pending = ConcurrentHashMap.newKeySet();
private final Set<Long> failed = ConcurrentHashMap.newKeySet(); private final Set<Long> failed = ConcurrentHashMap.newKeySet();
@@ -101,11 +100,34 @@ public final class IconRepository {
long id = normalize(iconId); long id = normalize(iconId);
byte[] data = get(id); byte[] data = get(id);
if (data == null) return null; 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); if (!file.isFile()) writeDiskCache(id, data);
return file.isFile() ? file : null; 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. */ /** Forgets download failures so icons can be retried on the next reconnect. */
public void retryFailed() { public void retryFailed() {
failed.clear(); failed.clear();
@@ -152,8 +174,12 @@ public final class IconRepository {
} }
} }
private byte[] readDiskCache(long id) { private static File cacheDir() {
File file = new File(cacheDir, "icon_" + id + ".png"); 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; if (!file.isFile()) return null;
try { try {
return Files.readAllBytes(file.toPath()); return Files.readAllBytes(file.toPath());
@@ -165,8 +191,8 @@ public final class IconRepository {
private void writeDiskCache(long id, byte[] data) { private void writeDiskCache(long id, byte[] data) {
try { try {
//noinspection ResultOfMethodCallIgnored //noinspection ResultOfMethodCallIgnored
cacheDir.mkdirs(); cacheDir().mkdirs();
Files.write(new File(cacheDir, "icon_" + id + ".png").toPath(), data); Files.write(new File(cacheDir(), "icon_" + id + ".png").toPath(), data);
} catch (IOException ignored) { } catch (IOException ignored) {
// the in-memory cache still serves this session // the in-memory cache still serves this session
} }

View File

@@ -297,6 +297,18 @@ public final class ServerModel {
return serverProperties.getOrDefault(key, ""); 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() { public synchronized int defaultChannelGroupId() {
return 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 * @param peerName the recipient's display name for a private message, else empty
*/ */
void onOwnChat(ChatScope scope, int peerClientId, String peerName, String text); 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; private final Settings settings;
@@ -60,6 +67,8 @@ public final class ServerSession {
private volatile Target lastTarget; private volatile Target lastTarget;
/** The channel we were in when the connection last went, to return to on a reconnect. */ /** The channel we were in when the connection last went, to return to on a reconnect. */
private volatile String lastChannelPath = ""; 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) { private record Target(String address, int port, String nickname, String password, String channelPassword) {
} }
@@ -437,6 +446,15 @@ public final class ServerSession {
setStatus(message); 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. */ /** Applies what a connection event means for the session, then passes it on. */
private final class Events implements ConnectionListener { private final class Events implements ConnectionListener {
@@ -452,11 +470,13 @@ public final class ServerSession {
listener.onConnected(); listener.onConnected();
listener.onSystemMessage("Connected."); listener.onSystemMessage("Connected.");
listener.onSessionChanged(); listener.onSessionChanged();
checkServerIcon();
} }
@Override @Override
public void onDisconnected(String reason) { public void onDisconnected(String reason) {
connecting = false; connecting = false;
serverIconId = -1;
conn.getModel().clear(); conn.getModel().clear();
listener.onDisconnected(reason); listener.onDisconnected(reason);
listener.onSystemMessage("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason)); listener.onSystemMessage("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
@@ -473,6 +493,7 @@ public final class ServerSession {
title = name; title = name;
listener.onSessionChanged(); listener.onSessionChanged();
} }
checkServerIcon();
listener.onModelChanged(); listener.onModelChanged();
} }

View File

@@ -11,7 +11,9 @@ import com.ts3client.teamspeak.TeamSpeakSettingsDb;
import javax.swing.Box; import javax.swing.Box;
import javax.swing.BoxLayout; import javax.swing.BoxLayout;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel; import javax.swing.DefaultListModel;
import javax.swing.ImageIcon;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JCheckBox; import javax.swing.JCheckBox;
import javax.swing.JDialog; import javax.swing.JDialog;
@@ -25,6 +27,7 @@ import javax.swing.JSplitPane;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.ListSelectionModel; import javax.swing.ListSelectionModel;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension; import java.awt.Dimension;
import java.awt.Frame; import java.awt.Frame;
import java.awt.GridBagConstraints; import java.awt.GridBagConstraints;
@@ -75,6 +78,16 @@ public final class BookmarksDialog extends JDialog {
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
list.setVisibleRowCount(12); 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 -> { list.addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) onSelectionChanged(); 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 == null || bounds.y + bounds.height < visible.y) continue;
if (bounds.y > visible.y + visible.height) break; if (bounds.y > visible.y + visible.height) break;
TreePath path = getPathForRow(row); List<Icon> icons = badgesOf((DefaultMutableTreeNode) getPathForRow(row).getLastPathComponent());
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
List<Icon> icons = badgesOf(obj);
if (icons.isEmpty()) continue; if (icons.isEmpty()) continue;
GroupIcons.Row strip = new GroupIcons.Row(icons); 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). */ /** 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) { if (node instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) node; ClientEntry cl = (ClientEntry) node;
List<Icon> icons = groupIcons.iconsOf( 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. */ /** Tree rows are 16&times;16 like TeamSpeak's own icon set. */
private static final int SIZE = 16; private static final int SIZE = 16;
private static final Map<Long, ImageIcon> DISK_CACHED = new ConcurrentHashMap<>();
private final IconRepository repository; private final IconRepository repository;
private final Map<Long, ImageIcon> decoded = new ConcurrentHashMap<>(); private final Map<Long, ImageIcon> decoded = new ConcurrentHashMap<>();
@@ -39,27 +41,52 @@ public final class GroupIcons {
public ImageIcon icon(long iconId) { public ImageIcon icon(long iconId) {
if (iconId == 0) return null; if (iconId == 0) return null;
// TeamSpeak's default group icons belong to the icon pack, which draws them in ImageIcon themed = themed(iconId);
// its own style; only server-uploaded icons come off the wire. The theme keeps if (themed != null) return themed;
// 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));
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;
byte[] data = repository.get(iconId); ImageIcon icon = decode(repository.get(iconId));
if (data == null) return null; 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); ImageIcon icon = new ImageIcon(data);
if (icon.getIconWidth() <= 0) return null; if (icon.getIconWidth() <= 0) return null;
int size = UIScale.scale(SIZE); int size = UIScale.scale(SIZE);
if (icon.getIconWidth() != size || icon.getIconHeight() != size) { if (icon.getIconWidth() != size || icon.getIconHeight() != size) {
icon = new ImageIcon(icon.getImage().getScaledInstance(size, size, Image.SCALE_SMOOTH)); icon = new ImageIcon(icon.getImage().getScaledInstance(size, size, Image.SCALE_SMOOTH));
} }
decoded.put(iconId, icon);
return 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.teamspeak.TeamSpeakImporter;
import com.ts3client.contacts.ContactStore; import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.HostButton;
import com.ts3client.net.PictureDownloads; import com.ts3client.net.PictureDownloads;
import com.ts3client.session.SelfState; import com.ts3client.session.SelfState;
import com.ts3client.session.ServerSession; import com.ts3client.session.ServerSession;
@@ -368,7 +369,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
revalidate(); revalidate();
repaint(); repaint();
} }
}); }, pictures);
} }
/** Rebuilds the icon-bearing chrome after the active icon pack changed. */ /** 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(); 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, … */ /** The local client on {@code tab} started or stopped talking, muted itself, … */
void selfStateChanged(ServerTab tab) { void selfStateChanged(ServerTab tab) {
if (tabs.contains(tab)) updateTray(); 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 away = connected && selected.session().isAway();
boolean commander = connected && selected.session().isCommander(); boolean commander = connected && selected.session().isCommander();
toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away); toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away);
toolbar.showHostButton(hostButtonOf(selected));
menuBar.refresh(connected, away, commander); menuBar.refresh(connected, away, commander);
updateTray(); 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 * The server the tray icon speaks for: the one holding the microphone, or the
* visible one when nobody is capturing. * 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.Bookmark;
import com.ts3client.config.Bookmarks; import com.ts3client.config.Bookmarks;
import javax.swing.ImageIcon;
import javax.swing.JCheckBoxMenuItem; import javax.swing.JCheckBoxMenuItem;
import javax.swing.JMenu; import javax.swing.JMenu;
import javax.swing.JMenuBar; import javax.swing.JMenuBar;
@@ -111,6 +112,21 @@ final class MainMenuBar extends JMenuBar {
bookmarksMenu = new JMenu("Bookmarks"); bookmarksMenu = new JMenu("Bookmarks");
rebuildBookmarks(); 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"); JMenu self = new JMenu("Self");
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE")); JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE"));
@@ -218,7 +234,8 @@ final class MainMenuBar extends JMenuBar {
void rebuildBookmarks() { void rebuildBookmarks() {
bookmarksMenu.removeAll(); bookmarksMenu.removeAll();
for (Bookmark b : bookmarks.all()) { 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.setToolTipText("Connect here (middle-click to open a new tab)");
item.addActionListener(e -> listener.onConnectBookmark(b, false)); item.addActionListener(e -> listener.onConnectBookmark(b, false));
item.addMouseListener(new MouseAdapter() { 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.ScaledEmptyBorder;
import com.formdev.flatlaf.util.UIScale; import com.formdev.flatlaf.util.UIScale;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.net.HostButton;
import com.ts3client.net.PictureDownloads;
import javax.swing.Box; import javax.swing.Box;
import javax.swing.JButton; 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 * 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 * controls, and the Master Volume slider and the server's host button on the
* the bar opens a customization menu for the window's optional chrome (this * right. A right-click anywhere on the bar opens a customization menu for the
* toolbar has no say over the status bar's own visibility beyond reporting the * window's optional chrome (this toolbar has no say over the status bar's own
* toggle). * visibility beyond reporting the toggle).
* *
* <p>Talks to {@link MainFrame} only through {@link Listener}, so it owns no * <p>Talks to {@link MainFrame} only through {@link Listener}, so it owns no
* connection/tab state itself &mdash; {@link #refresh} is handed everything it * 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 JToggleButton speakerButton;
private final DropDownToggleButton awayButton; private final DropDownToggleButton awayButton;
private final JComponent masterVolumePanel; private final JComponent masterVolumePanel;
private final HostButtonView hostButton;
MainToolbar(Settings settings, Listener listener) { MainToolbar(Settings settings, Listener listener, PictureDownloads pictures) {
this.settings = settings; this.settings = settings;
this.listener = listener; this.listener = listener;
@@ -120,6 +123,13 @@ final class MainToolbar extends JToolBar {
masterVolumePanel = buildMasterVolumeControl(); masterVolumePanel = buildMasterVolumeControl();
masterVolumePanel.setVisible(settings.showMasterVolumeSlider); masterVolumePanel.setVisible(settings.showMasterVolumeSlider);
add(masterVolumePanel); 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. */ /** Reflects the current tab's state on the buttons. */

View File

@@ -111,6 +111,7 @@ final class ServerTabConnectionEvents implements ServerSession.Listener {
treePanel.rebuild(); treePanel.rebuild();
treeActions.renderInfo(); treeActions.renderInfo();
treeActions.refreshGroupsDialog(); treeActions.refreshGroupsDialog();
host.refreshHostButton(tab);
if (session.isConnected()) { if (session.isConnected()) {
ChannelNode channel = session.currentChannel(); ChannelNode channel = session.currentChannel();
chatPanel.setServerName(conn.getModel().getServerName()); 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 @Override
public void onInfoUpdated() { public void onInfoUpdated() {
SwingUtilities.invokeLater(treeActions::renderInfo); SwingUtilities.invokeLater(treeActions::renderInfo);