diff --git a/android/app/src/main/java/com/ts3client/android/ServerController.kt b/android/app/src/main/java/com/ts3client/android/ServerController.kt index 3f8c2ef..7cf7989 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerController.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerController.kt @@ -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. diff --git a/android/app/src/main/java/com/ts3client/android/ServerTree.kt b/android/app/src/main/java/com/ts3client/android/ServerTree.kt index e7f7078..495ea99 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerTree.kt @@ -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, ) diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index f880e22..8617a76 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -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() diff --git a/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt index 87a850c..bd290f0 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt @@ -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, 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") } diff --git a/android/app/src/main/java/com/ts3client/android/ui/HostButton.kt b/android/app/src/main/java/com/ts3client/android/ui/HostButton.kt new file mode 100644 index 0000000..970ff2d --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/HostButton.kt @@ -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.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 diff --git a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt index 696abb9..a1aefaf 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt @@ -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") } diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java b/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java index c4ad559..142e331 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java @@ -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; diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java b/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java index d0d8613..4a0aa15 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java @@ -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 startupBookmarks() { List 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; } diff --git a/ts3-client/core/src/main/java/com/ts3client/net/HostButton.java b/ts3-client/core/src/main/java/com/ts3client/net/HostButton.java new file mode 100644 index 0000000..548676b --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/HostButton.java @@ -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()); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java b/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java index 8995310..6dc5a67 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java @@ -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 icons = new ConcurrentHashMap<>(); private final Set pending = ConcurrentHashMap.newKeySet(); private final Set 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 } diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index 2b9b8ef..4d286d7 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -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; } diff --git a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java index facfbb5..ad17fbd 100644 --- a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java +++ b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java @@ -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(); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java index b76edcf..b761516 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java @@ -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(); }); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java index e2bd6bd..64bbf88 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java @@ -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 icons = badgesOf(obj); + List 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 badgesOf(Object node) { + private List 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 icons = groupIcons.iconsOf( diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java index bc2637b..ea406ed 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java @@ -24,6 +24,8 @@ public final class GroupIcons { /** Tree rows are 16×16 like TeamSpeak's own icon set. */ private static final int SIZE = 16; + private static final Map DISK_CACHED = new ConcurrentHashMap<>(); + private final IconRepository repository; private final Map 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)); - if (themed != null) return themed; - } + 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; } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HostButtonView.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HostButtonView.java new file mode 100644 index 0000000..0395dfa --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HostButtonView.java @@ -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)); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 3c25d59..1b983cd 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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. diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java index 181e65e..5467abc 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java @@ -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() { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainToolbar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainToolbar.java index e5e4cd0..da1434f 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainToolbar.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainToolbar.java @@ -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). * *

Talks to {@link MainFrame} only through {@link Listener}, so it owns no * connection/tab state itself — {@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. */ diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java index 10cf9ec..960d0dc 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java @@ -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);