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:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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") }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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") }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public final class GroupIcons {
|
|||||||
/** Tree rows are 16×16 like TeamSpeak's own icon set. */
|
/** Tree rows are 16×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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 — {@link #refresh} is handed everything it
|
* 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 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. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user