diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4b05711..0c54292 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -54,4 +54,5 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.service) + implementation(libs.androidsvg) } diff --git a/android/app/src/main/java/com/ts3client/android/IconTheme.kt b/android/app/src/main/java/com/ts3client/android/IconTheme.kt new file mode 100644 index 0000000..c269f8c --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/IconTheme.kt @@ -0,0 +1,139 @@ +package com.ts3client.android + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.net.Uri +import android.provider.OpenableColumns +import android.util.LruCache +import com.caverock.androidsvg.RenderOptions +import com.caverock.androidsvg.SVG +import com.ts3client.config.AppDirs +import com.ts3client.config.Settings +import com.ts3client.gfx.IconPack +import com.ts3client.gfx.IconPacks +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.io.File + +/** + * The icon packs the app draws with: the Material pack it ships, and TeamSpeak packs + * imported into its `gfx` folder, which are the very zips a TeamSpeak 3 install keeps in + * its own. Whatever the chosen pack lacks comes from the Material one. + */ +class IconTheme(private val context: Context, private val settings: Settings) { + + private val folder = File(AppDirs.profile(), "gfx") + private val material: IconPack = IconPacks.material() + @Volatile + private var lookupOrder = listOf(material) + private val cache = LruCache(256) + + private val _packs = MutableStateFlow(emptyList()) + val packs: StateFlow> = _packs + + private val _active = MutableStateFlow(material) + val active: StateFlow = _active + + init { + reload() + } + + fun select(pack: IconPack) { + settings.iconPack = pack.id() + settings.save() + reload() + } + + /** + * Copies a pack's zip into the app and switches to it. + * + * @return the pack, or `null` when the file is no icon pack + */ + fun import(uri: Uri): IconPack? { + folder.mkdirs() + val target = File(folder, displayName(uri).substringAfterLast('/').let { + if (it.endsWith(".zip", ignoreCase = true)) it else "$it.zip" + }) + val staged = File(folder, target.name + ".part") + context.contentResolver.openInputStream(uri)?.use { input -> + staged.outputStream().use { input.copyTo(it) } + } ?: return null + target.delete() + staged.renameTo(target) + val pack = IconPack.load(target) + if (pack == null) { + target.delete() + return null + } + select(pack) + return _active.value + } + + /** + * Draws the first of [keys] the packs have. + * + * @param rgb the colour for art that paints in the text colour + */ + fun render(keys: List, size: Int, rgb: Int): Bitmap? { + val order = lookupOrder + val cacheKey = "${order.first().id()}/${keys.joinToString(",")}@$size#$rgb" + cache.get(cacheKey)?.let { return it } + for (key in keys) { + for (pack in order) { + val data = pack.icon(key, size) ?: continue + val bitmap = (if (pack.isVector(key, size)) rasterize(IconPack.withCurrentColor(data, rgb), size) + else decode(data, size)) ?: continue + cache.put(cacheKey, bitmap) + return bitmap + } + } + return null + } + + private fun reload() { + val packs = IconPacks.findAll(folder, null) + val active = IconPacks.select(packs, settings.iconPack) ?: material + lookupOrder = IconPacks.lookupOrder(active, packs, material) + cache.evictAll() + _packs.value = packs + _active.value = active + } + + private fun displayName(uri: Uri): String = + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { + if (it.moveToFirst()) it.getString(0) else null + } ?: uri.lastPathSegment ?: "iconpack.zip" + + private fun rasterize(svg: ByteArray, size: Int): Bitmap? = try { + val document = SVG.getFromString(String(svg, Charsets.UTF_8)) + if (document.documentViewBox == null && document.documentWidth > 0 && document.documentHeight > 0) { + document.setDocumentViewBox(0f, 0f, document.documentWidth, document.documentHeight) + } + // Otherwise the art keeps the size it was authored at inside the viewport. + document.setDocumentWidth("100%") + document.setDocumentHeight("100%") + Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).also { + document.renderToCanvas(Canvas(it), RenderOptions().viewPort(0f, 0f, size.toFloat(), size.toFloat())) + } + } catch (e: Exception) { + null + } + + /** Scales a bitmap icon into the square, keeping the aspect ratio of art that is not. */ + private fun decode(data: ByteArray, size: Int): Bitmap? { + val source = BitmapFactory.decodeByteArray(data, 0, data.size) ?: return null + if (source.width == size && source.height == size) return source + val factor = minOf(size.toFloat() / source.width, size.toFloat() / source.height) + val w = source.width * factor + val h = source.height * factor + return Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).also { + val left = (size - w) / 2 + val top = (size - h) / 2 + Canvas(it).drawBitmap(source, null, RectF(left, top, left + w, top + h), Paint(Paint.FILTER_BITMAP_FLAG)) + } + } +} 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 984fde0..84c6be7 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -64,6 +64,7 @@ sealed interface SessionEvent { class SessionController(private val context: Context) { val settings: Settings = Settings.load() + val icons = IconTheme(context, settings) private val bookmarkStore = Bookmarks.load() private val identities = IdentityStore.load(settings) private val audio = AndroidAudioBackend(context) diff --git a/android/app/src/main/java/com/ts3client/android/ui/App.kt b/android/app/src/main/java/com/ts3client/android/ui/App.kt index 4233234..1cac570 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/App.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/App.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -54,10 +55,12 @@ fun App(vm: SessionController) { else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO)) } - when { - settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false }) - state.busy || state.connected -> ServerScreen(vm, snackbar, { settingsOpen = true }, enableMicrophone) - else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking) + CompositionLocalProvider(LocalIconTheme provides vm.icons) { + when { + settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false }) + state.busy || state.connected -> ServerScreen(vm, snackbar, { settingsOpen = true }, enableMicrophone) + else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking) + } } poke?.let { p -> diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt index 0f2ab4a..e6f6aa2 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt @@ -15,33 +15,17 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.Chat -import androidx.compose.material.icons.filled.Bedtime -import androidx.compose.material.icons.filled.Block -import androidx.compose.material.icons.filled.Campaign -import androidx.compose.material.icons.filled.Circle -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material.icons.filled.HeadsetOff import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.MicOff -import androidx.compose.material.icons.filled.Terminal -import androidx.compose.material.icons.filled.VolumeOff -import androidx.compose.material.icons.outlined.Campaign -import androidx.compose.material.icons.outlined.Circle import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -50,6 +34,7 @@ import com.ts3client.android.ChannelItem import com.ts3client.android.ClientItem import com.ts3client.android.ServerTree import com.ts3client.contacts.ContactType +import com.ts3client.gfx.IconKeys import com.ts3client.net.ClientStatus /** One line of the tree as the list shows it. */ @@ -95,7 +80,7 @@ fun ChannelTree( LazyColumn(modifier.fillMaxSize()) { item(key = "server") { RowLayout(depth = 0, onClick = null) { - Icon(Icons.Filled.Dns, null, Modifier.size(ICON)) + PackIcon("SERVER_GREEN", null, size = ICON) Text(serverName, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) } } @@ -138,13 +123,7 @@ private fun ChannelRow(row: TreeRow.Channel, onToggle: (ChannelItem) -> Unit, on if (spacer != null) { SpacerLabel(spacer.align(), spacer.caption().orEmpty(), Modifier.weight(1f)) } else { - val icon = when { - channel.locked -> Icons.Filled.Lock - channel.full -> Icons.Filled.Block - else -> Icons.AutoMirrored.Outlined.Chat - } - Icon(icon, null, Modifier.size(ICON).alpha(if (channel.subscribed) 1f else 0.5f), - tint = if (channel.full && !channel.locked) StatusColors.muted else LocalContentColor.current) + PackIcon(IconKeys.channel(channel.locked, channel.full, channel.subscribed).asList(), null, size = ICON) Text( channel.name, Modifier.weight(1f), @@ -203,19 +182,5 @@ private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) { @Composable fun StatusIcon(status: ClientStatus, modifier: Modifier = Modifier) { - val (icon, tint) = statusIcon(status) - Icon(icon, status.name, modifier.size(ICON), tint = if (tint == Color.Unspecified) LocalContentColor.current else tint) -} - -private fun statusIcon(status: ClientStatus): Pair = when (status) { - ClientStatus.QUERY -> Icons.Filled.Terminal to Color.Unspecified - ClientStatus.SPEAKERS_DISABLED -> Icons.Filled.HeadsetOff to Color.Gray - ClientStatus.SPEAKERS_MUTED -> Icons.Filled.VolumeOff to StatusColors.muted - ClientStatus.MIC_DISABLED -> Icons.Filled.MicOff to Color.Gray - ClientStatus.MIC_MUTED -> Icons.Filled.MicOff to StatusColors.muted - ClientStatus.AWAY -> Icons.Filled.Bedtime to StatusColors.away - ClientStatus.COMMANDER_TALKING -> Icons.Filled.Campaign to StatusColors.talking - ClientStatus.COMMANDER -> Icons.Outlined.Campaign to Color.Unspecified - ClientStatus.TALKING -> Icons.Filled.Circle to StatusColors.talking - ClientStatus.IDLE -> Icons.Outlined.Circle to Color.Gray + PackIcon(IconKeys.client(status), status.name, modifier, size = ICON) } 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 c692ae5..7a28235 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 @@ -11,13 +11,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -61,7 +56,7 @@ fun ConnectScreen( topBar = { TopAppBar( title = { Text("TeamSpeak") }, - actions = { IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, "Settings") } }, + actions = { IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } }, ) }, snackbarHost = { SnackbarHost(snackbar) }, @@ -123,8 +118,8 @@ private fun BookmarkRow(bookmark: Bookmark, onConnect: () -> Unit, onRemove: () ListItem( headlineContent = { Text(bookmark.displayName()) }, supportingContent = { Text("${bookmark.address}:${bookmark.port}") }, - leadingContent = { Icon(Icons.Filled.Bookmark, null) }, - trailingContent = { IconButton(onClick = onRemove) { Icon(Icons.Filled.Delete, "Remove bookmark") } }, + leadingContent = { PackIcon("BOOKMARK_MANAGER", null) }, + trailingContent = { IconButton(onClick = onRemove) { PackIcon("DELETE", "Remove bookmark") } }, modifier = Modifier.clickable(onClick = onConnect), ) } diff --git a/android/app/src/main/java/com/ts3client/android/ui/MyTeamSpeakScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/MyTeamSpeakScreen.kt index 2f78598..233b328 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/MyTeamSpeakScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/MyTeamSpeakScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api @@ -110,7 +109,7 @@ fun MyTeamSpeakScreen(vm: SessionController, onBack: () -> Unit, onImported: () actions = { if (signedIn) { IconButton(onClick = { work { show(vm.fetchMyTeamSpeak()) } }, enabled = !busy) { - Icon(Icons.Filled.Refresh, "Refresh") + PackIcon("REFRESH", "Refresh") } } }, diff --git a/android/app/src/main/java/com/ts3client/android/ui/PackIcon.kt b/android/app/src/main/java/com/ts3client/android/ui/PackIcon.kt new file mode 100644 index 0000000..8683ee2 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/PackIcon.kt @@ -0,0 +1,42 @@ +package com.ts3client.android.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ts3client.android.IconTheme + +val LocalIconTheme = staticCompositionLocalOf { error("No icon theme provided") } + +/** An icon of the active pack, named by its TeamSpeak key; art in the text colour takes [LocalContentColor]. */ +@Composable +fun PackIcon(key: String, contentDescription: String?, modifier: Modifier = Modifier, size: Dp = 24.dp) = + PackIcon(listOf(key), contentDescription, modifier, size) + +/** The first of [keys] the pack has, for icons packs need not ship in every variant. */ +@Composable +fun PackIcon(keys: List, contentDescription: String?, modifier: Modifier = Modifier, size: Dp = 24.dp) { + val theme = LocalIconTheme.current + val active by theme.active.collectAsStateWithLifecycle() + val pixels = with(LocalDensity.current) { size.roundToPx() } + val color = LocalContentColor.current + val rgb = color.copy(alpha = 1f).toArgb() + val bitmap = remember(keys, pixels, rgb, active) { theme.render(keys, pixels, rgb)?.asImageBitmap() } + if (bitmap == null) { + Spacer(modifier.size(size)) + } else { + // Disabled controls fade their content colour; the pack's own colours fade with it. + Image(bitmap, contentDescription, modifier.size(size), alpha = color.alpha) + } +} 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 c1dd750..a5b6271 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 @@ -12,19 +12,10 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Logout -import androidx.compose.material.icons.filled.Bedtime -import androidx.compose.material.icons.filled.Headset -import androidx.compose.material.icons.filled.HeadsetOff -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.MicOff -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme @@ -46,6 +37,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalUriHandler @@ -126,8 +118,8 @@ fun ServerScreen( } }, actions = { - IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, "Settings") } - IconButton(onClick = vm::disconnect) { Icon(Icons.AutoMirrored.Filled.Logout, "Disconnect") } + IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } + IconButton(onClick = vm::disconnect) { PackIcon("DISCONNECT", "Disconnect") } }, ) }, @@ -222,22 +214,21 @@ private fun VoiceBar(state: SessionState, talking: Boolean, vm: SessionControlle verticalAlignment = Alignment.CenterVertically) { if (!state.micActive) { FilledTonalButton(onClick = onEnableMicrophone) { - Icon(Icons.Filled.Mic, null) + PackIcon("CAPTURE", null) Text("Use microphone", Modifier.padding(start = 8.dp)) } } else { IconToggleButton(state.micMuted, { vm.setMicMuted(it) }, enabled = state.connected) { - if (state.micMuted) Icon(Icons.Filled.MicOff, "Unmute microphone", tint = StatusColors.muted) - else Icon(Icons.Filled.Mic, "Mute microphone") + if (state.micMuted) PackIcon("INPUT_MUTED", "Unmute microphone") + else PackIcon("CAPTURE", "Mute microphone") } } IconToggleButton(state.deafened, { vm.setDeafened(it) }, enabled = state.connected) { - if (state.deafened) Icon(Icons.Filled.HeadsetOff, "Undeafen", tint = StatusColors.muted) - else Icon(Icons.Filled.Headset, "Deafen") + if (state.deafened) PackIcon("OUTPUT_MUTED", "Undeafen") + else PackIcon("PLAYBACK", "Deafen") } IconToggleButton(state.away, { vm.setAway(it) }, enabled = state.connected) { - Icon(Icons.Filled.Bedtime, if (state.away) "Back" else "Away", - tint = if (state.away) StatusColors.away else Color.Unspecified) + PackIcon("AWAY", if (state.away) "Back" else "Away", Modifier.alpha(if (state.away) 1f else 0.5f)) } } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt index afece6b..b881c0c 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt @@ -1,6 +1,8 @@ package com.ts3client.android.ui import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -31,6 +33,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -41,12 +44,16 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ts3client.android.IconTheme import com.ts3client.android.MicLevel import com.ts3client.android.SessionController import com.ts3client.android.audio.AudioRoute import com.ts3client.audio.AudioDevice import com.ts3client.audio.InputLevel import com.ts3client.config.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlin.math.roundToInt private val DENOISE_LEVELS = listOf("6 dB", "12 dB", "18 dB", "21 dB") @@ -186,6 +193,9 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) { change { outputVolume = it.toDouble() } } + Section("Appearance") + IconPackPicker(vm.icons) + Section("Account") Column(Modifier.fillMaxWidth().clickable { myTeamSpeak = true }.padding(horizontal = 16.dp, vertical = 8.dp)) { Text("myTeamSpeak") @@ -284,6 +294,29 @@ private fun Picker(label: String, options: List>, selected: } } +/** Chooses the icon pack, and brings in TeamSpeak packs from a file. */ +@Composable +private fun IconPackPicker(theme: IconTheme) { + val packs by theme.packs.collectAsStateWithLifecycle() + val active by theme.active.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + var failed by remember { mutableStateOf(null) } + val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) scope.launch { + val pack = withContext(Dispatchers.IO) { runCatching { theme.import(uri) }.getOrNull() } + failed = if (pack == null) "That file is not an icon pack." else null + } + } + Picker("Icon pack", packs.map { it to it.name() }, active) { theme.select(it) } + Column(Modifier.fillMaxWidth().clickable { importer.launch(arrayOf("application/zip", "application/octet-stream")) } + .padding(horizontal = 16.dp, vertical = 8.dp)) { + Text("Import icon pack") + Text("A TeamSpeak 3 pack (.zip), such as those in the gfx folder of a TeamSpeak 3 install", + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + failed?.let { Hint(it) } +} + /** Picks a device by its settings id; one that is not plugged in now still shows as chosen. */ @Composable private fun DevicePicker(label: String, devices: List, selected: String, onSelect: (String) -> Unit) { diff --git a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt index b2faf46..c9ba7a6 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt @@ -6,16 +6,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Login -import androidx.compose.material.icons.automirrored.outlined.Chat -import androidx.compose.material.icons.filled.Bedtime -import androidx.compose.material.icons.filled.NotificationsActive -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -67,10 +59,10 @@ fun ClientSheet( } } if (client.self) { - Action(Icons.Filled.Bedtime, if (away) "Back" else "Away") { onAway(!away) } + Action("AWAY", if (away) "Back" else "Away") { onAway(!away) } } else { - Action(Icons.AutoMirrored.Outlined.Chat, "Send message", onMessage) - Action(Icons.Filled.NotificationsActive, "Poke") { poking = true } + Action("PLAYER_CHAT", "Send message", onMessage) + Action("POKE", "Poke") { poking = true } var volume by remember { mutableFloatStateOf(initialVolume.toFloat()) } Text("Volume: ${if (volume > 0) "+" else ""}${volume.roundToInt()} dB", Modifier.padding(start = 24.dp, top = 8.dp), style = MaterialTheme.typography.bodyMedium) @@ -110,14 +102,14 @@ fun ChannelSheet( Text(channel.name, Modifier.padding(horizontal = 24.dp, vertical = 8.dp), style = MaterialTheme.typography.titleLarge) if (!current) { - Action(Icons.AutoMirrored.Filled.Login, "Join") { + Action("CHANNEL_SWITCH", "Join") { if (channel.locked) askPassword = true else onJoin(null) } } if (channel.subscribed) { - Action(Icons.Filled.VisibilityOff, "Unsubscribe") { onSubscribe(false) } + Action("UNSUBSCRIBE_FROM_CHANNEL", "Unsubscribe") { onSubscribe(false) } } else { - Action(Icons.Filled.Visibility, "Subscribe") { onSubscribe(true) } + Action("SUBSCRIBE_TO_CHANNEL", "Subscribe") { onSubscribe(true) } } } } @@ -134,10 +126,10 @@ fun ChannelSheet( } @Composable -private fun Action(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) { +private fun Action(iconKey: String, label: String, onClick: () -> Unit) { ListItem( headlineContent = { Text(label) }, - leadingContent = { Icon(icon, null) }, + leadingContent = { PackIcon(iconKey, null) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 8.dp), ) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index c57c290..db2d35d 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -7,6 +7,7 @@ lifecycle = "2.11.0" bouncycastle = "1.86" ini4j = "0.5.1" oboe = "1.11.0" +androidsvg = "1.4" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } @@ -16,6 +17,7 @@ androidx-compose-material-icons = { module = "androidx.compose.material:material androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" } +androidsvg = { module = "com.caverock:androidsvg-aar", version.ref = "androidsvg" } bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } ini4j = { module = "org.ini4j:ini4j", version.ref = "ini4j" } oboe = { module = "com.google.oboe:oboe", version.ref = "oboe" } diff --git a/ts3-client/README.md b/ts3-client/README.md index 511a511..d8e1d03 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -162,6 +162,11 @@ Android app in [`../android`](../android) does. TeamSpeak 3 client (`$TS3_CLIENT_DIR` or the usual install paths) and a folder of your choosing, so the official packs (`default_colored_2014.zip`, `default_mono_2014.zip`, the legacy `default.zip`) work unchanged. +- **Material**, a pack in the same format built from Google's Material icons, ships + with the client (`core/src/main/resources/gfx/material`, generated by + `tools/material-icon-pack.py`). Its monochrome icons paint with `currentColor`, which + follows the theme's text colour. It is the Android app's default, and the Android app + draws TeamSpeak packs too — import one (e.g. `default_colored_2014.zip`) in its settings. - The pack draws the tree (server, channel, client state), the toolbar, the menus, the context menus, the file browser and TeamSpeak's default group icons. Vector art is rasterised at the size it is drawn at; a pack's `FALLBACK` option decides @@ -240,8 +245,9 @@ core/ com.ts3client ├── contacts friends and blocked clients, in TS3's own record format ├── text BBCode, TeamSpeak links, chat search ├── gfx -│ ├── IconPack an icon pack zip/folder + its settings.ini mapping -│ └── IconPacks discovery of installed packs +│ ├── IconPack an icon pack zip/folder/classpath folder + its settings.ini mapping +│ ├── IconPacks discovery of installed packs, the shipped Material one, lookup order +│ └── IconKeys the icon keys the channel tree asks for ├── hotkey │ ├── HotkeyAction catalogue of TeamSpeak's hotkey actions (RE'd from its binary) │ ├── Hotkey/HotkeyCombo one binding: keys, trigger edge, server scope, argument diff --git a/ts3-client/core/src/main/java/com/ts3client/gfx/IconKeys.java b/ts3-client/core/src/main/java/com/ts3client/gfx/IconKeys.java new file mode 100644 index 0000000..0b0f1c6 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/gfx/IconKeys.java @@ -0,0 +1,37 @@ +package com.ts3client.gfx; + +import com.ts3client.net.ClientStatus; + +/** + * The TeamSpeak icon keys the channel tree is drawn with, so every frontend asks + * the packs for the same icons. + */ +public final class IconKeys { + + private IconKeys() { + } + + /** + * @return the keys to try for a channel, most specific first — packs need not ship + * every variant (none of TeamSpeak's own has a subscribed password channel) + */ + public static String[] channel(boolean password, boolean full, boolean subscribed) { + String base = password ? "CHANNEL_PRIVATE" : full ? "CHANNEL_RED" : "CHANNEL_GREEN"; + return subscribed ? new String[]{base + "_SUBSCRIBED", base} : new String[]{base}; + } + + public static String client(ClientStatus status) { + return switch (status) { + case QUERY -> "SERVER_QUERY"; + case SPEAKERS_DISABLED -> "HARDWARE_OUTPUT_MUTED"; + case SPEAKERS_MUTED -> "OUTPUT_MUTED"; + case MIC_DISABLED -> "HARDWARE_INPUT_MUTED"; + case MIC_MUTED -> "INPUT_MUTED"; + case AWAY -> "AWAY"; + case COMMANDER_TALKING -> "PLAYER_COMMANDER_ON"; + case COMMANDER -> "PLAYER_COMMANDER_OFF"; + case TALKING -> "PLAYER_ON"; + case IDLE -> "PLAYER_OFF"; + }; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/gfx/IconPack.java b/ts3-client/core/src/main/java/com/ts3client/gfx/IconPack.java index 993358d..9a7a881 100644 --- a/ts3-client/core/src/main/java/com/ts3client/gfx/IconPack.java +++ b/ts3-client/core/src/main/java/com/ts3client/gfx/IconPack.java @@ -39,7 +39,8 @@ import java.util.zip.ZipFile; * resolved by convention, picking the size closest to the one that is asked for. * *

Icon data is returned as bytes; rasterising it (the packs from 2014 on are - * vector art) is the frontend's job. + * vector art) is the frontend's job. Vector art may paint with {@code currentColor}, + * which the frontend sets to its text colour ({@link #withCurrentColor}). */ public final class IconPack { @@ -51,20 +52,26 @@ public final class IconPack { private record Entry(String path, int size) { } + /** Where a pack's files are read from: a zip, a directory or the classpath. */ + private interface Storage { + /** @return the file's bytes, or {@code null} when there is no such file */ + byte[] read(String path) throws IOException; + } + private final String id; private final File source; private final String name; - private final boolean zipped; + private final Storage storage; private final boolean fallback; /** Icon key to the images that can serve it, smallest first. */ private final Map> entries; - private IconPack(String id, File source, String name, boolean zipped, boolean fallback, + private IconPack(String id, File source, String name, Storage storage, boolean fallback, Map> entries) { this.id = id; this.source = source; this.name = name; - this.zipped = zipped; + this.storage = storage; this.fallback = fallback; this.entries = entries; } @@ -78,6 +85,7 @@ public final class IconPack { return name; } + /** @return the pack's zip or directory, or {@code null} for one shipped with the client */ public File source() { return source; } @@ -108,7 +116,7 @@ public final class IconPack { Entry entry = select(entries.get(normalizeKey(key)), preferredSize); if (entry == null) return null; try { - return zipped ? readFromZip(entry.path()) : Files.readAllBytes(new File(source, entry.path()).toPath()); + return storage.read(entry.path()); } catch (IOException e) { return null; } @@ -134,14 +142,16 @@ public final class IconPack { return candidates.get(candidates.size() - 1); } - private byte[] readFromZip(String path) throws IOException { - try (ZipFile zip = new ZipFile(source)) { - ZipEntry entry = zip.getEntry(path); - if (entry == null) return null; - try (InputStream in = zip.getInputStream(entry)) { - return in.readAllBytes(); - } - } + /** + * Sets the colour vector art paints with {@code currentColor}, as a stylesheet's + * {@code color} would. + * + * @param rgb the colour as {@code 0xRRGGBB} + */ + public static byte[] withCurrentColor(byte[] svg, int rgb) { + String text = new String(svg, StandardCharsets.UTF_8); + if (!text.contains("currentColor")) return svg; + return text.replace("currentColor", String.format("#%06X", rgb & 0xFFFFFF)).getBytes(StandardCharsets.UTF_8); } private static String normalizeKey(String key) { @@ -162,6 +172,7 @@ public final class IconPack { Map files = zipped ? listZip(source) : listDirectory(source); if (files == null || files.isEmpty()) return null; + Storage storage = zipped ? path -> readFromZip(source, path) : path -> readFromDirectory(source, path); String iniPath = files.keySet().stream() .filter(p -> p.equalsIgnoreCase(INI)) @@ -170,7 +181,7 @@ public final class IconPack { Map info = new LinkedHashMap<>(); boolean fallback = true; if (iniPath != null) { - byte[] ini = read(source, zipped, iniPath); + byte[] ini = read(storage, iniPath); if (ini != null) fallback = parseIni(ini, mapping, info); } @@ -181,7 +192,35 @@ public final class IconPack { String id = source.getName(); String name = info.getOrDefault("name", displayName(id)); - return new IconPack(id, source, name, zipped, fallback, entries); + return new IconPack(id, source, name, storage, fallback, entries); + } + + /** + * Reads a pack shipped on the classpath. As a classpath folder cannot be listed, + * the pack needs a {@code settings.ini}, whose file names must match exactly. + * + * @param id the pack's identifier in the settings + * @param resource the folder holding it, e.g. {@code /gfx/material} + * @return the pack, or {@code null} when it is not there + */ + public static IconPack loadResource(String id, String resource) { + Storage storage = path -> { + try (InputStream in = IconPack.class.getResourceAsStream(resource + '/' + path)) { + return in == null ? null : in.readAllBytes(); + } + }; + byte[] ini = read(storage, INI); + if (ini == null) return null; + Map mapping = new LinkedHashMap<>(); + Map info = new LinkedHashMap<>(); + boolean fallback = parseIni(ini, mapping, info); + + Map> entries = new LinkedHashMap<>(); + for (Map.Entry e : mapping.entrySet()) { + entries.put(e.getKey(), List.of(new Entry(e.getValue(), 0))); + } + if (entries.isEmpty()) return null; + return new IconPack(id, null, info.getOrDefault("name", displayName(id)), storage, fallback, entries); } /** Turns {@code default_colored_2014.zip} into {@code Default Colored 2014}. */ @@ -278,21 +317,29 @@ public final class IconPack { return null; } - private static byte[] read(File source, boolean zipped, String path) { + private static byte[] read(Storage storage, String path) { try { - if (!zipped) return Files.readAllBytes(new File(source, path).toPath()); - try (ZipFile zip = new ZipFile(source)) { - ZipEntry entry = zip.getEntry(path); - if (entry == null) return null; - try (InputStream in = zip.getInputStream(entry)) { - return in.readAllBytes(); - } - } + return storage.read(path); } catch (IOException e) { return null; } } + private static byte[] readFromZip(File zipFile, String path) throws IOException { + try (ZipFile zip = new ZipFile(zipFile)) { + ZipEntry entry = zip.getEntry(path); + if (entry == null) return null; + try (InputStream in = zip.getInputStream(entry)) { + return in.readAllBytes(); + } + } + } + + private static byte[] readFromDirectory(File dir, String path) throws IOException { + File file = new File(dir, path); + return file.isFile() ? Files.readAllBytes(file.toPath()) : null; + } + private static Map listZip(File file) { if (!file.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) return null; Map files = new LinkedHashMap<>(); diff --git a/ts3-client/core/src/main/java/com/ts3client/gfx/IconPacks.java b/ts3-client/core/src/main/java/com/ts3client/gfx/IconPacks.java index 348ece9..451a3d4 100644 --- a/ts3-client/core/src/main/java/com/ts3client/gfx/IconPacks.java +++ b/ts3-client/core/src/main/java/com/ts3client/gfx/IconPacks.java @@ -14,12 +14,16 @@ import java.util.Map; * Finds the icon packs installed on this machine: the client's own {@code gfx/} * folder, an extra folder the user configured, and the {@code gfx/} folder of an * installed TeamSpeak 3 client (whose packs are usable as they are, see - * {@link IconPack}). + * {@link IconPack}) — plus the Material pack the client ships. */ public final class IconPacks { /** The pack every other one falls back to, as TeamSpeak does. */ public static final String DEFAULT_ID = "default.zip"; + /** The pack shipped with the client, drawn from Google's Material icons. */ + public static final String MATERIAL_ID = "material"; + + private static volatile IconPack material; private IconPacks() { } @@ -44,11 +48,19 @@ public final class IconPacks { if (pack != null) byId.put(pack.id(), pack); } } + IconPack shipped = material(); + if (shipped != null) byId.putIfAbsent(shipped.id(), shipped); List packs = new ArrayList<>(byId.values()); packs.sort(Comparator.comparing(p -> p.name().toLowerCase(Locale.ROOT))); return packs; } + /** @return the Material pack, or {@code null} if the build left it out */ + public static IconPack material() { + if (material == null) material = IconPack.loadResource(MATERIAL_ID, "/gfx/material"); + return material; + } + /** @return the pack with this id, or {@code null} when it is not installed */ public static IconPack select(List packs, String id) { for (IconPack p : packs) { @@ -57,8 +69,19 @@ public final class IconPacks { return null; } - /** @return the pack unresolved icons are taken from, or {@code null} */ - public static IconPack fallback(List packs) { - return select(packs, DEFAULT_ID); + /** + * The packs to look an icon up in, in turn: the active one, TeamSpeak's + * {@code default.zip} if the active pack asks for it, then {@code lastResort}. + * + * @param active the chosen pack, or {@code null} for none + * @param lastResort the pack for whatever is still missing, or {@code null} + */ + public static List lookupOrder(IconPack active, List packs, IconPack lastResort) { + List order = new ArrayList<>(3); + if (active != null) order.add(active); + IconPack fallback = select(packs, DEFAULT_ID); + if (active != null && active.fallsBack() && fallback != null && !order.contains(fallback)) order.add(fallback); + if (lastResort != null && !order.contains(lastResort)) order.add(lastResort); + return order; } } diff --git a/ts3-client/core/src/main/resources/gfx/material/add.svg b/ts3-client/core/src/main/resources/gfx/material/add.svg new file mode 100644 index 0000000..8b330f9 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/add.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/add_a_photo.svg b/ts3-client/core/src/main/resources/gfx/material/add_a_photo.svg new file mode 100644 index 0000000..e60d452 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/add_a_photo.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/add_comment-outlined.svg b/ts3-client/core/src/main/resources/gfx/material/add_comment-outlined.svg new file mode 100644 index 0000000..024b6bb --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/add_comment-outlined.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/badge.svg b/ts3-client/core/src/main/resources/gfx/material/badge.svg new file mode 100644 index 0000000..e285786 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/badge.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/bedtime-9e8a2e.svg b/ts3-client/core/src/main/resources/gfx/material/bedtime-9e8a2e.svg new file mode 100644 index 0000000..2f3676d --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/bedtime-9e8a2e.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/block-d9443a-a50.svg b/ts3-client/core/src/main/resources/gfx/material/block-d9443a-a50.svg new file mode 100644 index 0000000..00b4c2a --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/block-d9443a-a50.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/block-d9443a.svg b/ts3-client/core/src/main/resources/gfx/material/block-d9443a.svg new file mode 100644 index 0000000..702dc4f --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/block-d9443a.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/bookmark.svg b/ts3-client/core/src/main/resources/gfx/material/bookmark.svg new file mode 100644 index 0000000..4cdb423 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/bookmark.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/bookmark_add.svg b/ts3-client/core/src/main/resources/gfx/material/bookmark_add.svg new file mode 100644 index 0000000..0413518 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/bookmark_add.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/bookmark_remove.svg b/ts3-client/core/src/main/resources/gfx/material/bookmark_remove.svg new file mode 100644 index 0000000..a726386 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/bookmark_remove.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/campaign-2e9bf0.svg b/ts3-client/core/src/main/resources/gfx/material/campaign-2e9bf0.svg new file mode 100644 index 0000000..0235422 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/campaign-2e9bf0.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/campaign-outlined.svg b/ts3-client/core/src/main/resources/gfx/material/campaign-outlined.svg new file mode 100644 index 0000000..e20db78 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/campaign-outlined.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/chat-outlined-a50.svg b/ts3-client/core/src/main/resources/gfx/material/chat-outlined-a50.svg new file mode 100644 index 0000000..ba1ff29 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/chat-outlined-a50.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/chat-outlined.svg b/ts3-client/core/src/main/resources/gfx/material/chat-outlined.svg new file mode 100644 index 0000000..e590a2b --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/chat-outlined.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/circle-2e9bf0.svg b/ts3-client/core/src/main/resources/gfx/material/circle-2e9bf0.svg new file mode 100644 index 0000000..99d4e22 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/circle-2e9bf0.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/circle-outlined-888888.svg b/ts3-client/core/src/main/resources/gfx/material/circle-outlined-888888.svg new file mode 100644 index 0000000..a85426e --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/circle-outlined-888888.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/close.svg b/ts3-client/core/src/main/resources/gfx/material/close.svg new file mode 100644 index 0000000..2a987a5 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/close.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/contacts.svg b/ts3-client/core/src/main/resources/gfx/material/contacts.svg new file mode 100644 index 0000000..5287856 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/contacts.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/content_copy.svg b/ts3-client/core/src/main/resources/gfx/material/content_copy.svg new file mode 100644 index 0000000..e2a0de2 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/content_copy.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/create_new_folder.svg b/ts3-client/core/src/main/resources/gfx/material/create_new_folder.svg new file mode 100644 index 0000000..ae7d462 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/create_new_folder.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/delete.svg b/ts3-client/core/src/main/resources/gfx/material/delete.svg new file mode 100644 index 0000000..9d7f15a --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/delete.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/dns.svg b/ts3-client/core/src/main/resources/gfx/material/dns.svg new file mode 100644 index 0000000..303e3a7 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/dns.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/download.svg b/ts3-client/core/src/main/resources/gfx/material/download.svg new file mode 100644 index 0000000..db4d005 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/download.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/drive_file_rename_outline.svg b/ts3-client/core/src/main/resources/gfx/material/drive_file_rename_outline.svg new file mode 100644 index 0000000..8f596e3 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/drive_file_rename_outline.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/drive_folder_upload.svg b/ts3-client/core/src/main/resources/gfx/material/drive_folder_upload.svg new file mode 100644 index 0000000..fc1da16 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/drive_folder_upload.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/edit.svg b/ts3-client/core/src/main/resources/gfx/material/edit.svg new file mode 100644 index 0000000..5a151b7 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/edit.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/error.svg b/ts3-client/core/src/main/resources/gfx/material/error.svg new file mode 100644 index 0000000..e699460 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/error.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/folder.svg b/ts3-client/core/src/main/resources/gfx/material/folder.svg new file mode 100644 index 0000000..1c78856 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/folder.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/gavel.svg b/ts3-client/core/src/main/resources/gfx/material/gavel.svg new file mode 100644 index 0000000..7288551 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/gavel.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/group_work-outlined.svg b/ts3-client/core/src/main/resources/gfx/material/group_work-outlined.svg new file mode 100644 index 0000000..2649a39 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/group_work-outlined.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/groups.svg b/ts3-client/core/src/main/resources/gfx/material/groups.svg new file mode 100644 index 0000000..a634e36 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/groups.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/headset_off-888888.svg b/ts3-client/core/src/main/resources/gfx/material/headset_off-888888.svg new file mode 100644 index 0000000..cc1acff --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/headset_off-888888.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/horizontal_rule.svg b/ts3-client/core/src/main/resources/gfx/material/horizontal_rule.svg new file mode 100644 index 0000000..0ed3443 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/horizontal_rule.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/info-outlined.svg b/ts3-client/core/src/main/resources/gfx/material/info-outlined.svg new file mode 100644 index 0000000..1554e06 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/info-outlined.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/info.svg b/ts3-client/core/src/main/resources/gfx/material/info.svg new file mode 100644 index 0000000..49ecb75 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/info.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/list.svg b/ts3-client/core/src/main/resources/gfx/material/list.svg new file mode 100644 index 0000000..72bc43c --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/list.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/lock-a50.svg b/ts3-client/core/src/main/resources/gfx/material/lock-a50.svg new file mode 100644 index 0000000..1f9ac81 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/lock-a50.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/lock.svg b/ts3-client/core/src/main/resources/gfx/material/lock.svg new file mode 100644 index 0000000..ac980f6 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/lock.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/login.svg b/ts3-client/core/src/main/resources/gfx/material/login.svg new file mode 100644 index 0000000..5d0be33 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/login.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/logout.svg b/ts3-client/core/src/main/resources/gfx/material/logout.svg new file mode 100644 index 0000000..83916c2 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/logout.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/mic.svg b/ts3-client/core/src/main/resources/gfx/material/mic.svg new file mode 100644 index 0000000..c1843a0 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/mic.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/mic_off-888888.svg b/ts3-client/core/src/main/resources/gfx/material/mic_off-888888.svg new file mode 100644 index 0000000..a07b074 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/mic_off-888888.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/mic_off-9e8a2e.svg b/ts3-client/core/src/main/resources/gfx/material/mic_off-9e8a2e.svg new file mode 100644 index 0000000..1c1400a --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/mic_off-9e8a2e.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/mic_off-d9443a.svg b/ts3-client/core/src/main/resources/gfx/material/mic_off-d9443a.svg new file mode 100644 index 0000000..5882087 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/mic_off-d9443a.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/move_down.svg b/ts3-client/core/src/main/resources/gfx/material/move_down.svg new file mode 100644 index 0000000..6010203 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/move_down.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/no_photography.svg b/ts3-client/core/src/main/resources/gfx/material/no_photography.svg new file mode 100644 index 0000000..4e8abe1 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/no_photography.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/notifications_active.svg b/ts3-client/core/src/main/resources/gfx/material/notifications_active.svg new file mode 100644 index 0000000..2c457b4 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/notifications_active.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/person_add.svg b/ts3-client/core/src/main/resources/gfx/material/person_add.svg new file mode 100644 index 0000000..5a70a85 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/person_add.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/person_off.svg b/ts3-client/core/src/main/resources/gfx/material/person_off.svg new file mode 100644 index 0000000..dc4af22 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/person_off.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/person_remove-outlined.svg b/ts3-client/core/src/main/resources/gfx/material/person_remove-outlined.svg new file mode 100644 index 0000000..e1d8d99 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/person_remove-outlined.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/person_remove.svg b/ts3-client/core/src/main/resources/gfx/material/person_remove.svg new file mode 100644 index 0000000..7c4c144 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/person_remove.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/power_settings_new.svg b/ts3-client/core/src/main/resources/gfx/material/power_settings_new.svg new file mode 100644 index 0000000..7810d92 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/power_settings_new.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/record_voice_over-2e9bf0.svg b/ts3-client/core/src/main/resources/gfx/material/record_voice_over-2e9bf0.svg new file mode 100644 index 0000000..8d84097 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/record_voice_over-2e9bf0.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/refresh.svg b/ts3-client/core/src/main/resources/gfx/material/refresh.svg new file mode 100644 index 0000000..3a7e853 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/refresh.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/remove_circle.svg b/ts3-client/core/src/main/resources/gfx/material/remove_circle.svg new file mode 100644 index 0000000..63f5942 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/remove_circle.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/remove_circle_outline.svg b/ts3-client/core/src/main/resources/gfx/material/remove_circle_outline.svg new file mode 100644 index 0000000..097a0c4 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/remove_circle_outline.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/settings.ini b/ts3-client/core/src/main/resources/gfx/material/settings.ini new file mode 100644 index 0000000..8a69425 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/settings.ini @@ -0,0 +1,90 @@ +[info] +name = Material + +[options] +# Complete for what the client draws; the rest stays with the client's own icons. +FALLBACK=false + +[gfxfiles] +SERVER_GREEN = dns.svg +CHANNEL_GREEN = chat-outlined-a50.svg +CHANNEL_GREEN_SUBSCRIBED = chat-outlined.svg +CHANNEL_PRIVATE = lock-a50.svg +CHANNEL_PRIVATE_SUBSCRIBED = lock.svg +CHANNEL_RED = block-d9443a-a50.svg +CHANNEL_RED_SUBSCRIBED = block-d9443a.svg +CHANNEL_CHAT = chat-outlined.svg +CHANNEL_COMMANDER = campaign-outlined.svg +PLAYER_OFF = circle-outlined-888888.svg +PLAYER_ON = circle-2e9bf0.svg +PLAYER_COMMANDER_OFF = campaign-outlined.svg +PLAYER_COMMANDER_ON = campaign-2e9bf0.svg +AWAY = bedtime-9e8a2e.svg +SERVER_QUERY = terminal.svg +INPUT_MUTED = mic_off-d9443a.svg +INPUT_MUTED_LOCAL = mic_off-9e8a2e.svg +OUTPUT_MUTED = volume_off-d9443a.svg +HARDWARE_INPUT_MUTED = mic_off-888888.svg +HARDWARE_OUTPUT_MUTED = headset_off-888888.svg +CAPTURE = mic.svg +PLAYBACK = volume_up.svg +VOLUME = volume_up.svg +ACTIVATE_MICROPHONE = record_voice_over-2e9bf0.svg +CONNECT = login.svg +DISCONNECT = logout.svg +QUIT = power_settings_new.svg +SETTINGS = settings.svg +REFRESH = refresh.svg +FILE_REFRESH = refresh.svg +INFO = info.svg +ABOUT = info-outlined.svg +WARNING = warning.svg +ERROR = error.svg +CHANNEL_SWITCH = login.svg +CHANNEL_CREATE = add.svg +CHANNEL_CREATE_SUB = subdirectory_arrow_right.svg +CHANNEL_CREATE_SPACER = horizontal_rule.svg +CHANNEL_EDIT = edit.svg +CHANNEL_DELETE = delete.svg +SUBSCRIBE_TO_CHANNEL = visibility.svg +UNSUBSCRIBE_FROM_CHANNEL = visibility_off.svg +SUBSCRIBE_TO_CHANNEL_FAMILY = visibility-outlined.svg +UNSUBSCRIBE_FROM_CHANNEL_FAMILY = visibility_off-outlined.svg +SUBSCRIBE_TO_ALL_CHANNELS = visibility.svg +UNSUBSCRIBE_FROM_ALL_CHANNELS = visibility_off.svg +PLAYER_CHAT = chat-outlined.svg +NEW_CHAT = add_comment-outlined.svg +POKE = notifications_active.svg +CHANGE_NICKNAME = drive_file_rename_outline.svg +MOVE_CLIENT_TO_OWN_CHANNEL = move_down.svg +KICK_FROM_CHANNEL = remove_circle_outline.svg +KICK_FROM_SERVER = remove_circle.svg +BAN_CLIENT = gavel.svg +BAN_LIST = list.svg +PERMISSIONS_SERVER_GROUPS = groups.svg +PERMISSIONS_CHANNEL_GROUPS = group_work-outlined.svg +ADD_FRIEND = person_add.svg +REMOVE_FRIEND = person_remove.svg +ADD_FOE = person_off.svg +REMOVE_FOE = person_remove-outlined.svg +EDIT_FRIEND_FOE_STATUS = contacts.svg +CONTACT = contacts.svg +IDENTITY_MANAGER = badge.svg +BOOKMARK_MANAGER = bookmark.svg +BOOKMARK_ADD = bookmark_add.svg +BOOKMARK_REMOVE = bookmark_remove.svg +UPLOAD_AVATAR = add_a_photo.svg +DELETE_AVATAR = no_photography.svg +ADD = add.svg +EDIT = edit.svg +DELETE = delete.svg +COPY = content_copy.svg +DEFAULT = star.svg +CLOSE_BUTTON = close.svg +TAB_CLOSE_BUTTON = close.svg +FOLDER = folder.svg +ADD_FOLDER = create_new_folder.svg +FILE_UP = drive_folder_upload.svg +FILETRANSFER = swap_vert.svg +UPLOAD = upload.svg +DOWNLOAD = download.svg diff --git a/ts3-client/core/src/main/resources/gfx/material/settings.svg b/ts3-client/core/src/main/resources/gfx/material/settings.svg new file mode 100644 index 0000000..299f531 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/settings.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/star.svg b/ts3-client/core/src/main/resources/gfx/material/star.svg new file mode 100644 index 0000000..951d568 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/star.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/subdirectory_arrow_right.svg b/ts3-client/core/src/main/resources/gfx/material/subdirectory_arrow_right.svg new file mode 100644 index 0000000..7b65111 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/subdirectory_arrow_right.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/swap_vert.svg b/ts3-client/core/src/main/resources/gfx/material/swap_vert.svg new file mode 100644 index 0000000..7272c61 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/swap_vert.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/terminal.svg b/ts3-client/core/src/main/resources/gfx/material/terminal.svg new file mode 100644 index 0000000..ab3595e --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/terminal.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/upload.svg b/ts3-client/core/src/main/resources/gfx/material/upload.svg new file mode 100644 index 0000000..9f252f4 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/upload.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/visibility-outlined.svg b/ts3-client/core/src/main/resources/gfx/material/visibility-outlined.svg new file mode 100644 index 0000000..58ed384 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/visibility-outlined.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/visibility.svg b/ts3-client/core/src/main/resources/gfx/material/visibility.svg new file mode 100644 index 0000000..e01453c --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/visibility.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/visibility_off-outlined.svg b/ts3-client/core/src/main/resources/gfx/material/visibility_off-outlined.svg new file mode 100644 index 0000000..eedb465 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/visibility_off-outlined.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/visibility_off.svg b/ts3-client/core/src/main/resources/gfx/material/visibility_off.svg new file mode 100644 index 0000000..bb02769 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/visibility_off.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/volume_off-d9443a.svg b/ts3-client/core/src/main/resources/gfx/material/volume_off-d9443a.svg new file mode 100644 index 0000000..d31092e --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/volume_off-d9443a.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/volume_up.svg b/ts3-client/core/src/main/resources/gfx/material/volume_up.svg new file mode 100644 index 0000000..2b15b63 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/volume_up.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/main/resources/gfx/material/warning.svg b/ts3-client/core/src/main/resources/gfx/material/warning.svg new file mode 100644 index 0000000..6f7bd92 --- /dev/null +++ b/ts3-client/core/src/main/resources/gfx/material/warning.svg @@ -0,0 +1 @@ + diff --git a/ts3-client/core/src/test/java/com/ts3client/gfx/IconPackTest.java b/ts3-client/core/src/test/java/com/ts3client/gfx/IconPackTest.java index 7772fa0..815148f 100644 --- a/ts3-client/core/src/test/java/com/ts3client/gfx/IconPackTest.java +++ b/ts3-client/core/src/test/java/com/ts3client/gfx/IconPackTest.java @@ -1,5 +1,6 @@ package com.ts3client.gfx; +import com.ts3client.net.ClientStatus; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -124,4 +125,36 @@ class IconPackTest { assertNull(IconPack.load(Files.createDirectory(dir.resolve("empty")).toFile())); assertNull(IconPack.load(null)); } + + @Test + void shipsAMaterialPackForEveryIconTheTreeDraws() { + IconPack pack = IconPacks.material(); + assertNotNull(pack); + assertEquals("Material", pack.name()); + assertFalse(pack.fallsBack()); + assertNull(pack.source()); + for (ClientStatus status : ClientStatus.values()) { + String key = IconKeys.client(status); + assertTrue(pack.isVector(key, 16), key); + assertNotNull(pack.icon(key, 16), key); + } + for (boolean password : new boolean[]{false, true}) { + for (boolean full : new boolean[]{false, true}) { + for (boolean subscribed : new boolean[]{false, true}) { + for (String key : IconKeys.channel(password, full, subscribed)) { + assertNotNull(pack.icon(key, 16), key); + } + } + } + } + } + + @Test + void setsTheColourVectorArtPaintsWith() { + byte[] svg = "".getBytes(StandardCharsets.UTF_8); + assertEquals("", + new String(IconPack.withCurrentColor(svg, 0xFF0A0B0C), StandardCharsets.UTF_8)); + byte[] fixed = "".getBytes(StandardCharsets.UTF_8); + assertTrue(fixed == IconPack.withCurrentColor(fixed, 0)); + } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java index cfab7e0..338be66 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java @@ -43,7 +43,7 @@ final class DesignPanel extends JPanel { private static final int TILE = 44; /** Stands for "no pack": the icons the client draws itself. */ - private static final String BUILT_IN = "Built-in icons"; + private static final String BUILT_IN = "Plain (drawn by the client)"; /** The scale factors offered besides following the desktop (0). */ private static final double[] SCALES = {0, 1, 1.25, 1.5, 1.75, 2, 2.5, 3}; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java index 299f063..f0029f4 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java @@ -12,6 +12,8 @@ import com.ts3client.gfx.IconPacks; import javax.imageio.ImageIO; import javax.swing.ImageIcon; +import javax.swing.UIManager; +import java.awt.Color; import java.awt.Graphics2D; import java.awt.Image; import java.awt.RenderingHints; @@ -34,7 +36,8 @@ import java.util.concurrent.CopyOnWriteArrayList; * instead, so the client always has a complete set. * *

Rendering is cached per key and size: vector art is rasterised at the size it - * is asked for, bitmaps are scaled from the closest resolution the pack ships. + * is asked for, in the look-and-feel's text colour where it paints with + * {@code currentColor}; bitmaps are scaled from the closest resolution the pack ships. * {@link #SIZE} and {@link #TOOLBAR_SIZE} are unscaled; sizes passed in are pixels. */ public final class IconTheme { @@ -51,7 +54,8 @@ public final class IconTheme { private volatile List packs = List.of(); private volatile IconPack active; - private volatile IconPack fallback; + /** Where icons are looked up; what none of these has is drawn by {@link Icons}. */ + private volatile List lookupOrder = List.of(); private IconTheme() { } @@ -72,14 +76,23 @@ public final class IconTheme { /** Re-scans the pack folders and activates the one the settings name. */ public void reload(Settings settings) { packs = IconPacks.findAll(new File(AppDirs.profile(), "gfx"), settings.iconPackDir); - fallback = IconPacks.fallback(packs); - setPack(IconPacks.select(packs, settings.iconPack)); + IconPack pack = IconPacks.select(packs, settings.iconPack); + // The shipped pack survives a re-scan as the same object, so setPack alone may not notice. + lookupOrder = IconPacks.lookupOrder(pack, packs, null); + setPack(pack); } /** Switches to a pack (which may be {@code null} for the built-in icons). */ public void setPack(IconPack pack) { if (active == pack) return; active = pack; + lookupOrder = IconPacks.lookupOrder(pack, packs, null); + cache.clear(); + for (Runnable listener : listeners) listener.run(); + } + + /** Re-renders the icons, which follow the look-and-feel's text colour. */ + public void refresh() { cache.clear(); for (Runnable listener : listeners) listener.run(); } @@ -105,22 +118,29 @@ public final class IconTheme { ImageIcon cached = cache.get(cacheKey); if (cached != null) return cached; - ImageIcon icon = render(active, key, size); - if (icon == null && active != null && active.fallsBack() && fallback != active) { - icon = render(fallback, key, size); + ImageIcon icon = null; + for (IconPack pack : lookupOrder) { + icon = render(pack, key, size); + if (icon != null) break; } if (icon != null) cache.put(cacheKey, icon); return icon; } private static ImageIcon render(IconPack pack, String key, int size) { - if (pack == null) return null; byte[] data = pack.icon(key, size); if (data == null || data.length == 0) return null; - BufferedImage image = pack.isVector(key, size) ? rasterize(data, size) : decode(data, size); + BufferedImage image = pack.isVector(key, size) + ? rasterize(IconPack.withCurrentColor(data, textColor().getRGB()), size) + : decode(data, size); return image == null ? null : new ImageIcon(image); } + private static Color textColor() { + Color c = UIManager.getColor("Label.foreground"); + return c != null ? c : Color.BLACK; + } + private static BufferedImage rasterize(byte[] data, int size) { try { SVGDocument document = new SVGLoader().load( diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java index baddc68..b889519 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java @@ -1,6 +1,7 @@ package com.ts3client.ui; import com.formdev.flatlaf.util.UIScale; +import com.ts3client.gfx.IconKeys; import javax.swing.ImageIcon; import java.awt.BasicStroke; @@ -73,10 +74,7 @@ public final class Icons { return icon != null ? icon : make(size, fallback); } - /** - * Like {@link #themed(String, Painter)}, but tries the keys in turn — packs need not - * ship every variant (none of TeamSpeak's own has a subscribed password channel). - */ + /** Like {@link #themed(String, Painter)}, but tries the keys in turn. */ private static ImageIcon themed(String[] keys, Painter fallback) { for (String key : keys) { ImageIcon icon = IconTheme.icon(key, small()); @@ -108,27 +106,22 @@ public final class Icons { } public static ImageIcon channel(boolean subscribed) { - return themed(keys("CHANNEL_GREEN", subscribed), + return themed(IconKeys.channel(false, false, subscribed), g -> paintChannel(g, new Color(0x3E7CB1), false, subscribed)); } /** A channel nobody can join without its password. */ public static ImageIcon channelLocked(boolean subscribed) { - return themed(keys("CHANNEL_PRIVATE", subscribed), + return themed(IconKeys.channel(true, false, subscribed), g -> paintChannel(g, new Color(0x8A6D3B), true, subscribed)); } /** A channel that has reached its client limit. */ public static ImageIcon channelFull(boolean subscribed) { - return themed(keys("CHANNEL_RED", subscribed), + return themed(IconKeys.channel(false, true, subscribed), g -> paintChannel(g, new Color(0xA53F3F), false, subscribed)); } - /** The pack keys to try for a channel icon, most specific first. */ - private static String[] keys(String base, boolean subscribed) { - return subscribed ? new String[]{base + "_SUBSCRIBED", base} : new String[]{base}; - } - /** The chat tab for the current channel. */ public static ImageIcon channelChat() { ImageIcon icon = of("CHANNEL_CHAT"); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/LookAndFeelManager.java b/ts3-client/swing/src/main/java/com/ts3client/ui/LookAndFeelManager.java index 68b8a96..11ca10d 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/LookAndFeelManager.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/LookAndFeelManager.java @@ -43,6 +43,7 @@ public final class LookAndFeelManager { */ public static void switchTo(Settings.Appearance appearance) { install(appearance); + IconTheme.get().refresh(); FlatLaf.updateUI(); for (Window w : Window.getWindows()) { w.repaint(); diff --git a/ts3-client/tools/material-icon-pack.py b/ts3-client/tools/material-icon-pack.py new file mode 100644 index 0000000..4c4286f --- /dev/null +++ b/ts3-client/tools/material-icon-pack.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Builds the Material icon pack the client ships (core/src/main/resources/gfx/material). + +The pack is in TeamSpeak's own format (settings.ini + SVGs), so both frontends draw it +through the same code as any TeamSpeak pack. The art is Google's Material Icons +(Apache 2.0), the set the Android frontend was designed with. + +Monochrome icons are filled with `currentColor`, which the frontends replace with the +interface's text colour; status icons carry the fixed colours TeamSpeak gives them. +""" +import pathlib +import urllib.request + +SOURCE = "https://cdn.jsdelivr.net/npm/@material-design-icons/svg@0.14.15/{style}/{name}.svg" +OUT = pathlib.Path(__file__).resolve().parent.parent / "core/src/main/resources/gfx/material" + +TALKING = "#2E9BF0" +MUTED = "#D9443A" +AWAY = "#9E8A2E" +IDLE = "#888888" +UNSUBSCRIBED = 0.5 + +# key: (icon, style, colour, opacity); style "o" is Material's outlined variant. +ICONS = { + "SERVER_GREEN": ("dns",), + "CHANNEL_GREEN": ("chat", "o", None, UNSUBSCRIBED), + "CHANNEL_GREEN_SUBSCRIBED": ("chat", "o"), + "CHANNEL_PRIVATE": ("lock", "", None, UNSUBSCRIBED), + "CHANNEL_PRIVATE_SUBSCRIBED": ("lock",), + "CHANNEL_RED": ("block", "", MUTED, UNSUBSCRIBED), + "CHANNEL_RED_SUBSCRIBED": ("block", "", MUTED), + "CHANNEL_CHAT": ("chat", "o"), + "CHANNEL_COMMANDER": ("campaign", "o"), + + "PLAYER_OFF": ("circle", "o", IDLE), + "PLAYER_ON": ("circle", "", TALKING), + "PLAYER_COMMANDER_OFF": ("campaign", "o"), + "PLAYER_COMMANDER_ON": ("campaign", "", TALKING), + "AWAY": ("bedtime", "", AWAY), + "SERVER_QUERY": ("terminal",), + "INPUT_MUTED": ("mic_off", "", MUTED), + "INPUT_MUTED_LOCAL": ("mic_off", "", AWAY), + "OUTPUT_MUTED": ("volume_off", "", MUTED), + "HARDWARE_INPUT_MUTED": ("mic_off", "", IDLE), + "HARDWARE_OUTPUT_MUTED": ("headset_off", "", IDLE), + + "CAPTURE": ("mic",), + "PLAYBACK": ("volume_up",), + "VOLUME": ("volume_up",), + "ACTIVATE_MICROPHONE": ("record_voice_over", "", TALKING), + "CONNECT": ("login",), + "DISCONNECT": ("logout",), + "QUIT": ("power_settings_new",), + "SETTINGS": ("settings",), + "REFRESH": ("refresh",), + "FILE_REFRESH": ("refresh",), + "INFO": ("info",), + "ABOUT": ("info", "o"), + "WARNING": ("warning",), + "ERROR": ("error",), + + "CHANNEL_SWITCH": ("login",), + "CHANNEL_CREATE": ("add",), + "CHANNEL_CREATE_SUB": ("subdirectory_arrow_right",), + "CHANNEL_CREATE_SPACER": ("horizontal_rule",), + "CHANNEL_EDIT": ("edit",), + "CHANNEL_DELETE": ("delete",), + "SUBSCRIBE_TO_CHANNEL": ("visibility",), + "UNSUBSCRIBE_FROM_CHANNEL": ("visibility_off",), + "SUBSCRIBE_TO_CHANNEL_FAMILY": ("visibility", "o"), + "UNSUBSCRIBE_FROM_CHANNEL_FAMILY": ("visibility_off", "o"), + "SUBSCRIBE_TO_ALL_CHANNELS": ("visibility",), + "UNSUBSCRIBE_FROM_ALL_CHANNELS": ("visibility_off",), + + "PLAYER_CHAT": ("chat", "o"), + "NEW_CHAT": ("add_comment", "o"), + "POKE": ("notifications_active",), + "CHANGE_NICKNAME": ("drive_file_rename_outline",), + "MOVE_CLIENT_TO_OWN_CHANNEL": ("move_down",), + "KICK_FROM_CHANNEL": ("remove_circle_outline",), + "KICK_FROM_SERVER": ("remove_circle",), + "BAN_CLIENT": ("gavel",), + "BAN_LIST": ("list",), + "PERMISSIONS_SERVER_GROUPS": ("groups",), + "PERMISSIONS_CHANNEL_GROUPS": ("group_work", "o"), + "ADD_FRIEND": ("person_add",), + "REMOVE_FRIEND": ("person_remove",), + "ADD_FOE": ("person_off",), + "REMOVE_FOE": ("person_remove", "o"), + "EDIT_FRIEND_FOE_STATUS": ("contacts",), + "CONTACT": ("contacts",), + "IDENTITY_MANAGER": ("badge",), + "BOOKMARK_MANAGER": ("bookmark",), + "BOOKMARK_ADD": ("bookmark_add",), + "BOOKMARK_REMOVE": ("bookmark_remove",), + "UPLOAD_AVATAR": ("add_a_photo",), + "DELETE_AVATAR": ("no_photography",), + + "ADD": ("add",), + "EDIT": ("edit",), + "DELETE": ("delete",), + "COPY": ("content_copy",), + "DEFAULT": ("star",), + "CLOSE_BUTTON": ("close",), + "TAB_CLOSE_BUTTON": ("close",), + "FOLDER": ("folder",), + "ADD_FOLDER": ("create_new_folder",), + "FILE_UP": ("drive_folder_upload",), + "FILETRANSFER": ("swap_vert",), + "UPLOAD": ("upload",), + "DOWNLOAD": ("download",), +} + +INI_HEAD = """\ +[info] +name = Material + +[options] +# Complete for what the client draws; the rest stays with the client's own icons. +FALLBACK=false + +[gfxfiles] +""" + + +def file_name(icon, style, colour, opacity): + parts = [icon] + (["outlined"] if style == "o" else []) + if colour: + parts.append(colour.lstrip("#").lower()) + if opacity is not None: + parts.append(f"a{int(opacity * 100)}") + return "-".join(parts) + ".svg" + + +def fetch(icon, style): + url = SOURCE.format(style="outlined" if style == "o" else "filled", name=icon) + with urllib.request.urlopen(url) as response: + return response.read().decode() + + +def paint(svg, colour, opacity): + attributes = f' fill="{colour or "currentColor"}"' + if opacity is not None: + attributes += f' fill-opacity="{opacity}"' + return svg.replace("