Show clients' country flags beside their names, and optionally in the tree
The server places each client in a country (client_country, from its GeoIP database); a client's details now show that country's flag left of their name, as TeamSpeak's info frame does, on both clients. "Show country flags in the channel tree" (off by default, as TeamSpeak's EnableCountryFlags) also puts it last on client rows. The flags are TeamSpeak's countries.zip, which now ships with the icon packs; core's CountryFlags reads it, else a TeamSpeak 3 install's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -283,7 +283,8 @@ class ServerController internal constructor(
|
|||||||
delay(TREE_FRAME_MS)
|
delay(TREE_FRAME_MS)
|
||||||
treePending.set(false)
|
treePending.set(false)
|
||||||
if (structureChanged.getAndSet(false)) roots = conn.model.buildTree()
|
if (structureChanged.getAndSet(false)) roots = conn.model.buildTree()
|
||||||
_tree.value = ServerTree.of(roots, conn.model, conn.contacts, app.settings.colorContacts)
|
_tree.value = ServerTree.of(roots, conn.model, conn.contacts, app.settings.colorContacts,
|
||||||
|
app.settings.countryFlags)
|
||||||
val serverName = if (session.isConnected) conn.model.serverName.orEmpty() else ""
|
val serverName = if (session.isConnected) conn.model.serverName.orEmpty() else ""
|
||||||
val channelName = session.currentChannel()?.name.orEmpty()
|
val channelName = session.currentChannel()?.name.orEmpty()
|
||||||
_chats.update {
|
_chats.update {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ data class ServerTree(
|
|||||||
val channels: List<ChannelItem> = emptyList(),
|
val channels: List<ChannelItem> = emptyList(),
|
||||||
/** The server's own icon; 0 for none. */
|
/** The server's own icon; 0 for none. */
|
||||||
val serverIconId: Long = 0,
|
val serverIconId: Long = 0,
|
||||||
|
/** Whether client rows show their country's flag. */
|
||||||
|
val countryFlags: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun channel(id: Int): ChannelItem? = find(channels) { it.id == id }
|
fun channel(id: Int): ChannelItem? = find(channels) { it.id == id }
|
||||||
@@ -37,10 +39,12 @@ data class ServerTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun of(roots: List<ChannelNode>, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ServerTree =
|
fun of(roots: List<ChannelNode>, model: ServerModel, contacts: ContactStore, colorContacts: Boolean,
|
||||||
|
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.serverProperty("virtualserver_icon_id").toLongOrNull() ?: 0,
|
||||||
|
countryFlags,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun channel(c: ChannelNode, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ChannelItem {
|
private fun channel(c: ChannelNode, model: ServerModel, contacts: ContactStore, colorContacts: Boolean): ChannelItem {
|
||||||
@@ -75,6 +79,7 @@ data class ServerTree(
|
|||||||
groupIconIds = (model.serverGroupsOf(c.serverGroupIds) + listOfNotNull(model.channelGroup(c.channelGroupId)))
|
groupIconIds = (model.serverGroupsOf(c.serverGroupIds) + listOfNotNull(model.channelGroup(c.channelGroupId)))
|
||||||
.map { it.iconId }.filter { it != 0L },
|
.map { it.iconId }.filter { it != 0L },
|
||||||
avatarFlag = c.avatarFlag.orEmpty(),
|
avatarFlag = c.avatarFlag.orEmpty(),
|
||||||
|
country = c.country.orEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,4 +118,6 @@ data class ClientItem(
|
|||||||
val groupIconIds: List<Long>,
|
val groupIconIds: List<Long>,
|
||||||
/** The MD5 of the client's avatar; empty for none. */
|
/** The MD5 of the client's avatar; empty for none. */
|
||||||
val avatarFlag: String,
|
val avatarFlag: String,
|
||||||
|
/** The code of the country the server places the client in; empty when it cannot tell. */
|
||||||
|
val country: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -319,6 +319,12 @@ class SessionController(private val context: Context) {
|
|||||||
|
|
||||||
val soundPack: SoundPack? get() = sounds.pack()
|
val soundPack: SoundPack? get() = sounds.pack()
|
||||||
|
|
||||||
|
fun setCountryFlags(shown: Boolean) {
|
||||||
|
settings.countryFlags = shown
|
||||||
|
settings.save()
|
||||||
|
servers.value.forEach { it.scheduleTree(structure = false) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDescriptionPictures(shown: Boolean) {
|
fun setDescriptionPictures(shown: Boolean) {
|
||||||
settings.descriptionPictures = shown
|
settings.descriptionPictures = shown
|
||||||
settings.save()
|
settings.save()
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ fun ChannelTree(
|
|||||||
items(rows, key = { it.key }) { row ->
|
items(rows, key = { it.key }) { row ->
|
||||||
when (row) {
|
when (row) {
|
||||||
is TreeRow.Channel -> ChannelRow(row, onToggle, onChannel)
|
is TreeRow.Channel -> ChannelRow(row, onToggle, onChannel)
|
||||||
is TreeRow.Client -> ClientRow(row, onClient)
|
is TreeRow.Client -> ClientRow(row, tree.countryFlags, onClient)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,7 @@ private fun SpacerLabel(align: Char, caption: String, modifier: Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) {
|
private fun ClientRow(row: TreeRow.Client, countryFlags: Boolean, onClient: (ClientItem) -> Unit) {
|
||||||
val client = row.client
|
val client = row.client
|
||||||
RowLayout(row.depth, onClick = { onClient(client) }) {
|
RowLayout(row.depth, onClick = { onClient(client) }) {
|
||||||
Spacer(Modifier.width(ICON + 4.dp))
|
Spacer(Modifier.width(ICON + 4.dp))
|
||||||
@@ -193,6 +193,8 @@ private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (id in client.groupIconIds) ServerIcon(id, size = ICON)
|
for (id in client.groupIconIds) ServerIcon(id, size = ICON)
|
||||||
|
// The country's flag comes last, at the row's edge.
|
||||||
|
if (countryFlags) CountryFlag(client.country)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ts3client.android.ui
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.drawable.AnimatedImageDrawable
|
import android.graphics.drawable.AnimatedImageDrawable
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
@@ -18,6 +19,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
@@ -31,7 +33,10 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.ts3client.android.Picture
|
import com.ts3client.android.Picture
|
||||||
import com.ts3client.android.ServerImages
|
import com.ts3client.android.ServerImages
|
||||||
|
import com.ts3client.gfx.CountryFlags
|
||||||
import com.ts3client.net.IconRepository
|
import com.ts3client.net.IconRepository
|
||||||
|
import java.util.Optional
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/** The images of the server on screen. */
|
/** The images of the server on screen. */
|
||||||
@@ -118,3 +123,18 @@ private class MovingPainter(private val drawable: AnimatedImageDrawable) : Paint
|
|||||||
main.removeCallbacks(what)
|
main.removeCallbacks(what)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val flags = ConcurrentHashMap<String, Optional<ImageBitmap>>()
|
||||||
|
|
||||||
|
/** The flag of a country, at TeamSpeak's 16×11; nothing for no country or one without a flag. */
|
||||||
|
@Composable
|
||||||
|
fun CountryFlag(code: String, modifier: Modifier = Modifier) {
|
||||||
|
if (code.isEmpty()) return
|
||||||
|
val bitmap = remember(code) {
|
||||||
|
flags.getOrPut(code) {
|
||||||
|
Optional.ofNullable(CountryFlags.png(code)?.let { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() })
|
||||||
|
}.orElse(null)
|
||||||
|
} ?: return
|
||||||
|
Image(bitmap, CountryFlags.name(code), modifier.size(16.dp, 11.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
|||||||
"MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK }
|
"MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK }
|
||||||
Section("Appearance")
|
Section("Appearance")
|
||||||
IconPackPicker(vm.icons)
|
IconPackPicker(vm.icons)
|
||||||
|
var flags by remember { mutableStateOf(vm.settings.countryFlags) }
|
||||||
|
Toggle("Show country flags in the channel tree", flags) {
|
||||||
|
flags = it
|
||||||
|
vm.setCountryFlags(it)
|
||||||
|
}
|
||||||
var pictures by remember { mutableStateOf(vm.settings.descriptionPictures) }
|
var pictures by remember { mutableStateOf(vm.settings.descriptionPictures) }
|
||||||
Toggle("Show pictures in descriptions", pictures) {
|
Toggle("Show pictures in descriptions", pictures) {
|
||||||
pictures = it
|
pictures = it
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ fun ClientSheet(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
StatusIcon(client.status)
|
StatusIcon(client.status)
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(client.name, style = MaterialTheme.typography.titleLarge)
|
// The country's flag goes before the name, as in TeamSpeak's info frame.
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
CountryFlag(client.country)
|
||||||
|
Text(client.name, style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
if (client.name != client.nickname) Text(client.nickname, style = MaterialTheme.typography.bodyMedium)
|
if (client.name != client.nickname) Text(client.nickname, style = MaterialTheme.typography.bodyMedium)
|
||||||
if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium)
|
if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ val ts3ClientDir: String = providers.gradleProperty("ts3.client.dir")
|
|||||||
.orElse(providers.environmentVariable("TS3_CLIENT_DIR"))
|
.orElse(providers.environmentVariable("TS3_CLIENT_DIR"))
|
||||||
.getOrElse(rootDir.resolve("../TeamSpeak3-Client-linux_amd64").path)
|
.getOrElse(rootDir.resolve("../TeamSpeak3-Client-linux_amd64").path)
|
||||||
|
|
||||||
/** TeamSpeak's own icon packs, proprietary and so taken from an install, as the desktop's build does. */
|
/** TeamSpeak's own icon packs and country flags, proprietary and so taken from an install, as the desktop's build does. */
|
||||||
val teamspeakIconPacks = tasks.register<Sync>("teamspeakIconPacks") {
|
val teamspeakIconPacks = tasks.register<Sync>("teamspeakIconPacks") {
|
||||||
into(layout.buildDirectory.dir("generated/iconPacks"))
|
into(layout.buildDirectory.dir("generated/iconPacks"))
|
||||||
from(File(ts3ClientDir, "gfx")) {
|
from(File(ts3ClientDir, "gfx")) {
|
||||||
include("default.zip", "default_colored_2014.zip", "default_mono_2014.zip")
|
include("default.zip", "default_colored_2014.zip", "default_mono_2014.zip", "countries.zip")
|
||||||
into("gfx/teamspeak")
|
into("gfx/teamspeak")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<resource>
|
<resource>
|
||||||
<directory>src/main/resources</directory>
|
<directory>src/main/resources</directory>
|
||||||
</resource>
|
</resource>
|
||||||
<!-- TeamSpeak's own icon packs, proprietary and so taken from an install, not the repository. -->
|
<!-- TeamSpeak's own icon packs and country flags, proprietary and so taken from an install, not the repository. -->
|
||||||
<resource>
|
<resource>
|
||||||
<directory>${ts3.client.dir}/gfx</directory>
|
<directory>${ts3.client.dir}/gfx</directory>
|
||||||
<targetPath>gfx/teamspeak</targetPath>
|
<targetPath>gfx/teamspeak</targetPath>
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
<include>default.zip</include>
|
<include>default.zip</include>
|
||||||
<include>default_colored_2014.zip</include>
|
<include>default_colored_2014.zip</include>
|
||||||
<include>default_mono_2014.zip</include>
|
<include>default_mono_2014.zip</include>
|
||||||
|
<include>countries.zip</include>
|
||||||
</includes>
|
</includes>
|
||||||
</resource>
|
</resource>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ public final class Settings {
|
|||||||
// ---- icons ----
|
// ---- icons ----
|
||||||
/** File name of the active icon pack; empty uses the built-in icons. */
|
/** File name of the active icon pack; empty uses the built-in icons. */
|
||||||
public String iconPack = "default_colored_2014.zip";
|
public String iconPack = "default_colored_2014.zip";
|
||||||
|
/**
|
||||||
|
* Whether the channel tree shows each client's country flag, as TeamSpeak's EnableCountryFlags;
|
||||||
|
* their details show it beside their name either way.
|
||||||
|
*/
|
||||||
|
public boolean countryFlags = false;
|
||||||
/** Extra folder to look for icon packs in, on top of the well-known locations. */
|
/** Extra folder to look for icon packs in, on top of the well-known locations. */
|
||||||
public String iconPackDir = "";
|
public String iconPackDir = "";
|
||||||
|
|
||||||
@@ -270,6 +275,7 @@ public final class Settings {
|
|||||||
soundPackDir = props.getProperty("soundPackDir", soundPackDir);
|
soundPackDir = props.getProperty("soundPackDir", soundPackDir);
|
||||||
desktopNotifications = parseB(props.getProperty("desktopNotifications"), desktopNotifications);
|
desktopNotifications = parseB(props.getProperty("desktopNotifications"), desktopNotifications);
|
||||||
iconPack = props.getProperty("iconPack", iconPack);
|
iconPack = props.getProperty("iconPack", iconPack);
|
||||||
|
countryFlags = parseB(props.getProperty("countryFlags"), countryFlags);
|
||||||
iconPackDir = props.getProperty("iconPackDir", iconPackDir);
|
iconPackDir = props.getProperty("iconPackDir", iconPackDir);
|
||||||
appearance = parseEnum(Appearance.class, props.getProperty("appearance"), appearance);
|
appearance = parseEnum(Appearance.class, props.getProperty("appearance"), appearance);
|
||||||
uiScale = parseD(props.getProperty("uiScale"), uiScale);
|
uiScale = parseD(props.getProperty("uiScale"), uiScale);
|
||||||
@@ -325,6 +331,7 @@ public final class Settings {
|
|||||||
props.setProperty("soundPackDir", soundPackDir);
|
props.setProperty("soundPackDir", soundPackDir);
|
||||||
props.setProperty("desktopNotifications", Boolean.toString(desktopNotifications));
|
props.setProperty("desktopNotifications", Boolean.toString(desktopNotifications));
|
||||||
props.setProperty("iconPack", iconPack);
|
props.setProperty("iconPack", iconPack);
|
||||||
|
props.setProperty("countryFlags", Boolean.toString(countryFlags));
|
||||||
props.setProperty("iconPackDir", iconPackDir);
|
props.setProperty("iconPackDir", iconPackDir);
|
||||||
props.setProperty("appearance", appearance.name());
|
props.setProperty("appearance", appearance.name());
|
||||||
props.setProperty("uiScale", Double.toString(uiScale));
|
props.setProperty("uiScale", Double.toString(uiScale));
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.ts3client.gfx;
|
||||||
|
|
||||||
|
import com.ts3client.config.InstallDirs;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.IllformedLocaleException;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipInputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The flags TeamSpeak shows for the country the server places a client in: the
|
||||||
|
* {@code <code>.png} files of its {@code countries.zip}, the one the build shipped, else
|
||||||
|
* a TeamSpeak 3 install's.
|
||||||
|
*/
|
||||||
|
public final class CountryFlags {
|
||||||
|
|
||||||
|
private static final String RESOURCE = "/gfx/teamspeak/countries.zip";
|
||||||
|
private static final String FILE = "countries.zip";
|
||||||
|
|
||||||
|
private static volatile Map<String, byte[]> flags;
|
||||||
|
|
||||||
|
private CountryFlags() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the flag's PNG bytes, or {@code null} for an unknown or empty code */
|
||||||
|
public static byte[] png(String code) {
|
||||||
|
if (code == null || code.isEmpty()) return null;
|
||||||
|
return flags().get(code.toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the country's English name, or the code itself when it names none */
|
||||||
|
public static String name(String code) {
|
||||||
|
try {
|
||||||
|
String name = new Locale.Builder().setRegion(code).build().getDisplayCountry(Locale.ENGLISH);
|
||||||
|
return name.isEmpty() ? code : name;
|
||||||
|
} catch (IllformedLocaleException e) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, byte[]> flags() {
|
||||||
|
Map<String, byte[]> loaded = flags;
|
||||||
|
if (loaded != null) return loaded;
|
||||||
|
synchronized (CountryFlags.class) {
|
||||||
|
if (flags == null) flags = load();
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, byte[]> load() {
|
||||||
|
try (InputStream in = CountryFlags.class.getResourceAsStream(RESOURCE)) {
|
||||||
|
if (in != null) return read(in);
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// try an install's
|
||||||
|
}
|
||||||
|
for (File root : InstallDirs.searchRoots("gfx", null, null)) {
|
||||||
|
File zip = new File(root, FILE);
|
||||||
|
if (!zip.isFile()) continue;
|
||||||
|
try (InputStream in = new FileInputStream(zip)) {
|
||||||
|
return read(in);
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// try the next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, byte[]> read(InputStream in) throws IOException {
|
||||||
|
Map<String, byte[]> out = new HashMap<>();
|
||||||
|
try (ZipInputStream zip = new ZipInputStream(in)) {
|
||||||
|
for (ZipEntry e; (e = zip.getNextEntry()) != null; ) {
|
||||||
|
String name = e.getName().toLowerCase(Locale.ROOT);
|
||||||
|
if (!e.isDirectory() && name.endsWith(".png")) {
|
||||||
|
out.put(name.substring(name.lastIndexOf('/') + 1, name.length() - 4), zip.readAllBytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ public final class ClientEntry {
|
|||||||
public volatile String description = "";
|
public volatile String description = "";
|
||||||
/** MD5 of the client's avatar ({@code client_flag_avatar}); empty when they have none. */
|
/** MD5 of the client's avatar ({@code client_flag_avatar}); empty when they have none. */
|
||||||
public volatile String avatarFlag = "";
|
public volatile String avatarFlag = "";
|
||||||
|
/** Where the server places the client ({@code client_country}), as an ISO code; empty when it cannot tell. */
|
||||||
|
public volatile String country = "";
|
||||||
|
|
||||||
public volatile boolean talking;
|
public volatile boolean talking;
|
||||||
public volatile boolean inputMuted; // microphone muted (client_input_muted)
|
public volatile boolean inputMuted; // microphone muted (client_input_muted)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
|
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
|
||||||
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
|
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
|
||||||
c.avatarFlag = TeamspeakConnection.orEmpty(e.get("client_flag_avatar"));
|
c.avatarFlag = TeamspeakConnection.orEmpty(e.get("client_flag_avatar"));
|
||||||
|
c.country = TeamspeakConnection.orEmpty(e.get("client_country"));
|
||||||
c.databaseId = e.getClientDatabaseId();
|
c.databaseId = e.getClientDatabaseId();
|
||||||
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
||||||
c.channelGroupId = e.getClientChannelGroupId();
|
c.channelGroupId = e.getClientChannelGroupId();
|
||||||
@@ -338,6 +339,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
if (has(e, "client_is_channel_commander"))
|
if (has(e, "client_is_channel_commander"))
|
||||||
c.channelCommander = e.getBoolean("client_is_channel_commander");
|
c.channelCommander = e.getBoolean("client_is_channel_commander");
|
||||||
if (reported(e, "client_flag_avatar")) c.avatarFlag = TeamspeakConnection.orEmpty(e.get("client_flag_avatar"));
|
if (reported(e, "client_flag_avatar")) c.avatarFlag = TeamspeakConnection.orEmpty(e.get("client_flag_avatar"));
|
||||||
|
if (reported(e, "client_country")) c.country = TeamspeakConnection.orEmpty(e.get("client_country"));
|
||||||
announceClientUpdate(e, c, renamed, oldName);
|
announceClientUpdate(e, c, renamed, oldName);
|
||||||
conn.ui.onModelChanged();
|
conn.ui.onModelChanged();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.ts3client.gfx;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
|
||||||
|
class CountryFlagsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void namesCountriesInEnglishAndLeavesOddCodesAsTheyAre() {
|
||||||
|
assertEquals("Slovakia", CountryFlags.name("SK"));
|
||||||
|
assertEquals("Germany", CountryFlags.name("de"));
|
||||||
|
assertEquals("not a code", CountryFlags.name("not a code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasNoFlagForNoCountry() {
|
||||||
|
assertNull(CountryFlags.png(""));
|
||||||
|
assertNull(CountryFlags.png(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.formdev.flatlaf.util.UIScale;
|
||||||
|
import com.ts3client.gfx.CountryFlags;
|
||||||
|
|
||||||
|
import javax.swing.ImageIcon;
|
||||||
|
import java.awt.Image;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/** TeamSpeak's country flags as Swing icons, at their own 16×11 scaled with the interface. */
|
||||||
|
final class CountryFlagIcons {
|
||||||
|
|
||||||
|
private static final int WIDTH = 16;
|
||||||
|
private static final int HEIGHT = 11;
|
||||||
|
private static final Map<String, Optional<ImageIcon>> ICONS = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private CountryFlagIcons() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the flag, or {@code null} for an unknown or empty code */
|
||||||
|
static ImageIcon of(String code) {
|
||||||
|
if (code == null || code.isEmpty()) return null;
|
||||||
|
return ICONS.computeIfAbsent(code, CountryFlagIcons::load).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<ImageIcon> load(String code) {
|
||||||
|
byte[] png = CountryFlags.png(code);
|
||||||
|
if (png == null) return Optional.empty();
|
||||||
|
ImageIcon icon = new ImageIcon(png);
|
||||||
|
if (icon.getIconWidth() <= 0) return Optional.empty();
|
||||||
|
int width = UIScale.scale(WIDTH);
|
||||||
|
int height = UIScale.scale(HEIGHT);
|
||||||
|
if (icon.getIconWidth() != width || icon.getIconHeight() != height) {
|
||||||
|
icon = new ImageIcon(icon.getImage().getScaledInstance(width, height, Image.SCALE_SMOOTH));
|
||||||
|
}
|
||||||
|
return Optional.of(icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import javax.swing.DefaultListCellRenderer;
|
|||||||
import javax.swing.DefaultListModel;
|
import javax.swing.DefaultListModel;
|
||||||
import javax.swing.ImageIcon;
|
import javax.swing.ImageIcon;
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JCheckBox;
|
||||||
import javax.swing.JComboBox;
|
import javax.swing.JComboBox;
|
||||||
import javax.swing.JComponent;
|
import javax.swing.JComponent;
|
||||||
import javax.swing.JFileChooser;
|
import javax.swing.JFileChooser;
|
||||||
@@ -59,6 +60,7 @@ final class DesignPanel extends JPanel {
|
|||||||
private final JComboBox<Object> packCombo = new JComboBox<>();
|
private final JComboBox<Object> packCombo = new JComboBox<>();
|
||||||
private final JLabel packInfo = new JLabel();
|
private final JLabel packInfo = new JLabel();
|
||||||
private final JTextField packDirField;
|
private final JTextField packDirField;
|
||||||
|
private final JCheckBox countryFlags = new JCheckBox("Show country flags in the channel tree");
|
||||||
private final DefaultListModel<String> previewModel = new DefaultListModel<>();
|
private final DefaultListModel<String> previewModel = new DefaultListModel<>();
|
||||||
private final JList<String> preview = new JList<>(previewModel);
|
private final JList<String> preview = new JList<>(previewModel);
|
||||||
|
|
||||||
@@ -84,6 +86,7 @@ final class DesignPanel extends JPanel {
|
|||||||
settings.iconPackDir = packDirField.getText().trim();
|
settings.iconPackDir = packDirField.getText().trim();
|
||||||
Object selected = packCombo.getSelectedItem();
|
Object selected = packCombo.getSelectedItem();
|
||||||
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
|
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
|
||||||
|
settings.countryFlags = countryFlags.isSelected();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts back the appearance and pack the dialog started with, for a cancelled edit. */
|
/** Puts back the appearance and pack the dialog started with, for a cancelled edit. */
|
||||||
@@ -149,7 +152,12 @@ final class DesignPanel extends JPanel {
|
|||||||
dirRow.add(packDirField, BorderLayout.CENTER);
|
dirRow.add(packDirField, BorderLayout.CENTER);
|
||||||
dirRow.add(browse, BorderLayout.EAST);
|
dirRow.add(browse, BorderLayout.EAST);
|
||||||
packDirField.setToolTipText("Extra folder to look for icon packs in");
|
packDirField.setToolTipText("Extra folder to look for icon packs in");
|
||||||
addRow(p, c, row, new JLabel("Extra pack folder:"), dirRow);
|
addRow(p, c, row++, new JLabel("Extra pack folder:"), dirRow);
|
||||||
|
|
||||||
|
countryFlags.setSelected(settings.countryFlags);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.gridy = row;
|
||||||
|
p.add(countryFlags, c);
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.ts3client.ui;
|
package com.ts3client.ui;
|
||||||
|
|
||||||
import com.formdev.flatlaf.util.UIScale;
|
import com.formdev.flatlaf.util.UIScale;
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
import com.ts3client.net.ChannelNode;
|
import com.ts3client.net.ChannelNode;
|
||||||
import com.ts3client.net.Spacers;
|
import com.ts3client.net.Spacers;
|
||||||
import com.ts3client.net.ClientEntry;
|
import com.ts3client.net.ClientEntry;
|
||||||
@@ -40,6 +41,7 @@ final class DropIndicatorTree extends JTree {
|
|||||||
|
|
||||||
private final ServerModel model;
|
private final ServerModel model;
|
||||||
private final GroupIcons groupIcons;
|
private final GroupIcons groupIcons;
|
||||||
|
private final Settings settings;
|
||||||
/** Set while a drop would move a client into this channel row. */
|
/** Set while a drop would move a client into this channel row. */
|
||||||
private TreePath highlight;
|
private TreePath highlight;
|
||||||
/** The row the pointer is over, or -1 when it is over none. */
|
/** The row the pointer is over, or -1 when it is over none. */
|
||||||
@@ -48,10 +50,11 @@ final class DropIndicatorTree extends JTree {
|
|||||||
private Set<Object> searchHits = Collections.emptySet();
|
private Set<Object> searchHits = Collections.emptySet();
|
||||||
private Predicate<TreePath> pathEditable = path -> false;
|
private Predicate<TreePath> pathEditable = path -> false;
|
||||||
|
|
||||||
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) {
|
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons, Settings settings) {
|
||||||
super(treeModel);
|
super(treeModel);
|
||||||
this.model = model;
|
this.model = model;
|
||||||
this.groupIcons = groupIcons;
|
this.groupIcons = groupIcons;
|
||||||
|
this.settings = settings;
|
||||||
MouseAdapter hover = new MouseAdapter() {
|
MouseAdapter hover = new MouseAdapter() {
|
||||||
@Override
|
@Override
|
||||||
public void mouseMoved(MouseEvent e) {
|
public void mouseMoved(MouseEvent e) {
|
||||||
@@ -299,8 +302,12 @@ final class DropIndicatorTree extends JTree {
|
|||||||
private List<Icon> badgesOf(Object node) {
|
private List<Icon> badgesOf(Object node) {
|
||||||
if (node instanceof ClientEntry) {
|
if (node instanceof ClientEntry) {
|
||||||
ClientEntry cl = (ClientEntry) node;
|
ClientEntry cl = (ClientEntry) node;
|
||||||
return groupIcons.iconsOf(
|
List<Icon> icons = groupIcons.iconsOf(
|
||||||
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
|
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
|
||||||
|
// The country's flag comes last, at the row's edge, as in TeamSpeak.
|
||||||
|
Icon flag = settings.countryFlags ? CountryFlagIcons.of(cl.country) : null;
|
||||||
|
if (flag != null) icons.add(flag);
|
||||||
|
return icons;
|
||||||
}
|
}
|
||||||
if (node instanceof ChannelNode) {
|
if (node instanceof ChannelNode) {
|
||||||
ChannelNode ch = (ChannelNode) node;
|
ChannelNode ch = (ChannelNode) node;
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import java.util.Map;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icons of the active pack as PNG files, for HTML views, which only show pictures by URL.
|
* Icons of the active pack, and country flags, as PNG files, for HTML views, which only
|
||||||
* A pack change gives the theme new icons, and so new files.
|
* show pictures by URL. A pack change gives the theme new icons, and so new files.
|
||||||
*/
|
*/
|
||||||
final class IconFiles {
|
final class IconFiles {
|
||||||
|
|
||||||
@@ -31,6 +31,14 @@ final class IconFiles {
|
|||||||
return file.isFile() ? file : null;
|
return file.isFile() ? file : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return the country's flag as drawn in the tree, or {@code null} for none */
|
||||||
|
static File flag(String code) {
|
||||||
|
ImageIcon icon = CountryFlagIcons.of(code);
|
||||||
|
if (icon == null) return null;
|
||||||
|
File file = FILES.computeIfAbsent("flag_" + code + '_' + icon.getIconWidth() + ".png", n -> write(icon, n));
|
||||||
|
return file.isFile() ? file : null;
|
||||||
|
}
|
||||||
|
|
||||||
private static File write(ImageIcon icon, String name) {
|
private static File write(ImageIcon icon, String name) {
|
||||||
try {
|
try {
|
||||||
File file = new File(dir(), name);
|
File file = new File(dir(), name);
|
||||||
|
|||||||
@@ -241,8 +241,8 @@ public final class InfoPanel extends JPanel {
|
|||||||
|
|
||||||
private String body() {
|
private String body() {
|
||||||
showsPictures = false;
|
showsPictures = false;
|
||||||
if (shownServer != null) return html(Details.server(shownModel, shownServer), "", null, null);
|
if (shownServer != null) return html(Details.server(shownModel, shownServer), "", "", null, null);
|
||||||
if (shownChannel != null) return html(Details.channel(shownChannel), "", null, "No description.");
|
if (shownChannel != null) return html(Details.channel(shownChannel), "", "", null, "No description.");
|
||||||
if (shownClient != null) {
|
if (shownClient != null) {
|
||||||
renderedAvatarBox = inChatTab ? UIScale.scale(AvatarHtml.MAX_SIZE) : avatarBox();
|
renderedAvatarBox = inChatTab ? UIScale.scale(AvatarHtml.MAX_SIZE) : avatarBox();
|
||||||
return clientBody(shownClient, shownModel, shownIcons, shownContact, shownAvatar, renderedAvatarBox);
|
return clientBody(shownClient, shownModel, shownIcons, shownContact, shownAvatar, renderedAvatarBox);
|
||||||
@@ -278,17 +278,19 @@ public final class InfoPanel extends JPanel {
|
|||||||
|
|
||||||
private String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) {
|
private String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) {
|
||||||
String you = cl.self ? " <span class='muted'>(you)</span>" : "";
|
String you = cl.self ? " <span class='muted'>(you)</span>" : "";
|
||||||
return html(Details.client(cl, model, contact), you, icons, null);
|
// The country's flag goes before the name, as in TeamSpeak's info frame.
|
||||||
|
return html(Details.client(cl, model, contact), flagTag(cl.country), you, icons, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param titlePrefix HTML before the title
|
||||||
* @param titleSuffix HTML after the title
|
* @param titleSuffix HTML after the title
|
||||||
* @param icons for the icons of the groups a field names; null for none
|
* @param icons for the icons of the groups a field names; null for none
|
||||||
* @param none what to say when there is no description; null to say nothing
|
* @param none what to say when there is no description; null to say nothing
|
||||||
*/
|
*/
|
||||||
private String html(Details details, String titleSuffix, IconRepository icons, String none) {
|
private String html(Details details, String titlePrefix, String titleSuffix, IconRepository icons, String none) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append(heading(esc(details.title()) + titleSuffix));
|
sb.append(heading(titlePrefix + esc(details.title()) + titleSuffix));
|
||||||
for (Details.Field f : details.fields()) row(sb, esc(f.label()), value(f, icons));
|
for (Details.Field f : details.fields()) row(sb, esc(f.label()), value(f, icons));
|
||||||
if (!details.description().isEmpty()) {
|
if (!details.description().isEmpty()) {
|
||||||
sb.append("<hr><div>").append(description(details.description())).append("</div>");
|
sb.append("<hr><div>").append(description(details.description())).append("</div>");
|
||||||
@@ -327,6 +329,13 @@ public final class InfoPanel extends JPanel {
|
|||||||
return "<img src='" + file.toURI() + "' width='" + UIScale.scale(14) + "' height='" + UIScale.scale(14) + "'> ";
|
return "<img src='" + file.toURI() + "' width='" + UIScale.scale(14) + "' height='" + UIScale.scale(14) + "'> ";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An inline {@code <img>} for a country's flag, or nothing for no country. */
|
||||||
|
private static String flagTag(String country) {
|
||||||
|
File file = IconFiles.flag(country);
|
||||||
|
if (file == null) return "";
|
||||||
|
return "<img src='" + file.toURI() + "' width='" + UIScale.scale(16) + "' height='" + UIScale.scale(11) + "'> ";
|
||||||
|
}
|
||||||
|
|
||||||
private static String heading(String text) {
|
private static String heading(String text) {
|
||||||
return "<div style='font-weight:bold;font-size:13px;margin-bottom:" + HtmlStyles.px(4) + "'>" + text + "</div>";
|
return "<div style='font-weight:bold;font-size:13px;margin-bottom:" + HtmlStyles.px(4) + "'>" + text + "</div>";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ public final class ServerTreePanel extends JPanel {
|
|||||||
this.groupIcons = groupIcons;
|
this.groupIcons = groupIcons;
|
||||||
this.actions = actions;
|
this.actions = actions;
|
||||||
root.setUserObject("Not connected");
|
root.setUserObject("Not connected");
|
||||||
this.tree = new DropIndicatorTree(treeModel, model, groupIcons);
|
this.tree = new DropIndicatorTree(treeModel, model, groupIcons, settings);
|
||||||
tree.setRootVisible(true);
|
tree.setRootVisible(true);
|
||||||
// The server node is the only top-level row and always stays open, so it gets
|
// The server node is the only top-level row and always stays open, so it gets
|
||||||
// no expand control. Nesting is tightened too: horizontal space in this view
|
// no expand control. Nesting is tightened too: horizontal space in this view
|
||||||
|
|||||||
Reference in New Issue
Block a user