Share icon packs between desktop and Android

The Android app's Material icons become a TeamSpeak-format pack shipped in
core, which the desktop offers next to the TeamSpeak packs. Android now
draws its icons from packs too and imports TeamSpeak's own zips, falling
back to the Material pack for what they lack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 09:39:42 +00:00
parent a7aa122728
commit cdf81f3258
92 changed files with 786 additions and 139 deletions

View File

@@ -54,4 +54,5 @@ dependencies {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.service) implementation(libs.androidx.lifecycle.service)
implementation(libs.androidsvg)
} }

View File

@@ -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<String, Bitmap>(256)
private val _packs = MutableStateFlow(emptyList<IconPack>())
val packs: StateFlow<List<IconPack>> = _packs
private val _active = MutableStateFlow(material)
val active: StateFlow<IconPack> = _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<String>, 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))
}
}
}

View File

@@ -64,6 +64,7 @@ sealed interface SessionEvent {
class SessionController(private val context: Context) { class SessionController(private val context: Context) {
val settings: Settings = Settings.load() val settings: Settings = Settings.load()
val icons = IconTheme(context, settings)
private val bookmarkStore = Bookmarks.load() private val bookmarkStore = Bookmarks.load()
private val identities = IdentityStore.load(settings) private val identities = IdentityStore.load(settings)
private val audio = AndroidAudioBackend(context) private val audio = AndroidAudioBackend(context)

View File

@@ -9,6 +9,7 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -54,10 +55,12 @@ fun App(vm: SessionController) {
else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO)) else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO))
} }
when { CompositionLocalProvider(LocalIconTheme provides vm.icons) {
settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false }) when {
state.busy || state.connected -> ServerScreen(vm, snackbar, { settingsOpen = true }, enableMicrophone) settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false })
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking) state.busy || state.connected -> ServerScreen(vm, snackbar, { settingsOpen = true }, enableMicrophone)
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking)
}
} }
poke?.let { p -> poke?.let { p ->

View File

@@ -15,33 +15,17 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.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.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.ClientItem
import com.ts3client.android.ServerTree import com.ts3client.android.ServerTree
import com.ts3client.contacts.ContactType import com.ts3client.contacts.ContactType
import com.ts3client.gfx.IconKeys
import com.ts3client.net.ClientStatus import com.ts3client.net.ClientStatus
/** One line of the tree as the list shows it. */ /** One line of the tree as the list shows it. */
@@ -95,7 +80,7 @@ fun ChannelTree(
LazyColumn(modifier.fillMaxSize()) { LazyColumn(modifier.fillMaxSize()) {
item(key = "server") { item(key = "server") {
RowLayout(depth = 0, onClick = null) { 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) 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) { if (spacer != null) {
SpacerLabel(spacer.align(), spacer.caption().orEmpty(), Modifier.weight(1f)) SpacerLabel(spacer.align(), spacer.caption().orEmpty(), Modifier.weight(1f))
} else { } else {
val icon = when { PackIcon(IconKeys.channel(channel.locked, channel.full, channel.subscribed).asList(), null, size = ICON)
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)
Text( Text(
channel.name, channel.name,
Modifier.weight(1f), Modifier.weight(1f),
@@ -203,19 +182,5 @@ private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) {
@Composable @Composable
fun StatusIcon(status: ClientStatus, modifier: Modifier = Modifier) { fun StatusIcon(status: ClientStatus, modifier: Modifier = Modifier) {
val (icon, tint) = statusIcon(status) PackIcon(IconKeys.client(status), status.name, modifier, size = ICON)
Icon(icon, status.name, modifier.size(ICON), tint = if (tint == Color.Unspecified) LocalContentColor.current else tint)
}
private fun statusIcon(status: ClientStatus): Pair<ImageVector, Color> = 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
} }

View File

@@ -11,13 +11,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
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.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -61,7 +56,7 @@ fun ConnectScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("TeamSpeak") }, title = { Text("TeamSpeak") },
actions = { IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, "Settings") } }, actions = { IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } },
) )
}, },
snackbarHost = { SnackbarHost(snackbar) }, snackbarHost = { SnackbarHost(snackbar) },
@@ -123,8 +118,8 @@ private fun BookmarkRow(bookmark: Bookmark, onConnect: () -> Unit, onRemove: ()
ListItem( ListItem(
headlineContent = { Text(bookmark.displayName()) }, headlineContent = { Text(bookmark.displayName()) },
supportingContent = { Text("${bookmark.address}:${bookmark.port}") }, supportingContent = { Text("${bookmark.address}:${bookmark.port}") },
leadingContent = { Icon(Icons.Filled.Bookmark, null) }, leadingContent = { PackIcon("BOOKMARK_MANAGER", null) },
trailingContent = { IconButton(onClick = onRemove) { Icon(Icons.Filled.Delete, "Remove bookmark") } }, trailingContent = { IconButton(onClick = onRemove) { PackIcon("DELETE", "Remove bookmark") } },
modifier = Modifier.clickable(onClick = onConnect), modifier = Modifier.clickable(onClick = onConnect),
) )
} }

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -110,7 +109,7 @@ fun MyTeamSpeakScreen(vm: SessionController, onBack: () -> Unit, onImported: ()
actions = { actions = {
if (signedIn) { if (signedIn) {
IconButton(onClick = { work { show(vm.fetchMyTeamSpeak()) } }, enabled = !busy) { IconButton(onClick = { work { show(vm.fetchMyTeamSpeak()) } }, enabled = !busy) {
Icon(Icons.Filled.Refresh, "Refresh") PackIcon("REFRESH", "Refresh")
} }
} }
}, },

View File

@@ -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<IconTheme> { 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<String>, 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)
}
}

View File

@@ -12,19 +12,10 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape 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.Badge
import androidx.compose.material3.BadgedBox import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconToggleButton import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -46,6 +37,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
@@ -126,8 +118,8 @@ fun ServerScreen(
} }
}, },
actions = { actions = {
IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, "Settings") } IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
IconButton(onClick = vm::disconnect) { Icon(Icons.AutoMirrored.Filled.Logout, "Disconnect") } IconButton(onClick = vm::disconnect) { PackIcon("DISCONNECT", "Disconnect") }
}, },
) )
}, },
@@ -222,22 +214,21 @@ private fun VoiceBar(state: SessionState, talking: Boolean, vm: SessionControlle
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
if (!state.micActive) { if (!state.micActive) {
FilledTonalButton(onClick = onEnableMicrophone) { FilledTonalButton(onClick = onEnableMicrophone) {
Icon(Icons.Filled.Mic, null) PackIcon("CAPTURE", null)
Text("Use microphone", Modifier.padding(start = 8.dp)) Text("Use microphone", Modifier.padding(start = 8.dp))
} }
} else { } else {
IconToggleButton(state.micMuted, { vm.setMicMuted(it) }, enabled = state.connected) { IconToggleButton(state.micMuted, { vm.setMicMuted(it) }, enabled = state.connected) {
if (state.micMuted) Icon(Icons.Filled.MicOff, "Unmute microphone", tint = StatusColors.muted) if (state.micMuted) PackIcon("INPUT_MUTED", "Unmute microphone")
else Icon(Icons.Filled.Mic, "Mute microphone") else PackIcon("CAPTURE", "Mute microphone")
} }
} }
IconToggleButton(state.deafened, { vm.setDeafened(it) }, enabled = state.connected) { IconToggleButton(state.deafened, { vm.setDeafened(it) }, enabled = state.connected) {
if (state.deafened) Icon(Icons.Filled.HeadsetOff, "Undeafen", tint = StatusColors.muted) if (state.deafened) PackIcon("OUTPUT_MUTED", "Undeafen")
else Icon(Icons.Filled.Headset, "Deafen") else PackIcon("PLAYBACK", "Deafen")
} }
IconToggleButton(state.away, { vm.setAway(it) }, enabled = state.connected) { IconToggleButton(state.away, { vm.setAway(it) }, enabled = state.connected) {
Icon(Icons.Filled.Bedtime, if (state.away) "Back" else "Away", PackIcon("AWAY", if (state.away) "Back" else "Away", Modifier.alpha(if (state.away) 1f else 0.5f))
tint = if (state.away) StatusColors.away else Color.Unspecified)
} }
} }
} }

View File

@@ -1,6 +1,8 @@
package com.ts3client.android.ui package com.ts3client.android.ui
import androidx.activity.compose.BackHandler 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.Canvas
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -31,6 +33,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -41,12 +44,16 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.IconTheme
import com.ts3client.android.MicLevel import com.ts3client.android.MicLevel
import com.ts3client.android.SessionController import com.ts3client.android.SessionController
import com.ts3client.android.audio.AudioRoute import com.ts3client.android.audio.AudioRoute
import com.ts3client.audio.AudioDevice import com.ts3client.audio.AudioDevice
import com.ts3client.audio.InputLevel import com.ts3client.audio.InputLevel
import com.ts3client.config.Settings import com.ts3client.config.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt import kotlin.math.roundToInt
private val DENOISE_LEVELS = listOf("6 dB", "12 dB", "18 dB", "21 dB") 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() } change { outputVolume = it.toDouble() }
} }
Section("Appearance")
IconPackPicker(vm.icons)
Section("Account") Section("Account")
Column(Modifier.fillMaxWidth().clickable { myTeamSpeak = true }.padding(horizontal = 16.dp, vertical = 8.dp)) { Column(Modifier.fillMaxWidth().clickable { myTeamSpeak = true }.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text("myTeamSpeak") Text("myTeamSpeak")
@@ -284,6 +294,29 @@ private fun <T> Picker(label: String, options: List<Pair<T, String>>, 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<String?>(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. */ /** Picks a device by its settings id; one that is not plugged in now still shows as chosen. */
@Composable @Composable
private fun DevicePicker(label: String, devices: List<AudioDevice>, selected: String, onSelect: (String) -> Unit) { private fun DevicePicker(label: String, devices: List<AudioDevice>, selected: String, onSelect: (String) -> Unit) {

View File

@@ -6,16 +6,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding 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.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -67,10 +59,10 @@ fun ClientSheet(
} }
} }
if (client.self) { if (client.self) {
Action(Icons.Filled.Bedtime, if (away) "Back" else "Away") { onAway(!away) } Action("AWAY", if (away) "Back" else "Away") { onAway(!away) }
} else { } else {
Action(Icons.AutoMirrored.Outlined.Chat, "Send message", onMessage) Action("PLAYER_CHAT", "Send message", onMessage)
Action(Icons.Filled.NotificationsActive, "Poke") { poking = true } Action("POKE", "Poke") { poking = true }
var volume by remember { mutableFloatStateOf(initialVolume.toFloat()) } var volume by remember { mutableFloatStateOf(initialVolume.toFloat()) }
Text("Volume: ${if (volume > 0) "+" else ""}${volume.roundToInt()} dB", Text("Volume: ${if (volume > 0) "+" else ""}${volume.roundToInt()} dB",
Modifier.padding(start = 24.dp, top = 8.dp), style = MaterialTheme.typography.bodyMedium) 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), Text(channel.name, Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
style = MaterialTheme.typography.titleLarge) style = MaterialTheme.typography.titleLarge)
if (!current) { if (!current) {
Action(Icons.AutoMirrored.Filled.Login, "Join") { Action("CHANNEL_SWITCH", "Join") {
if (channel.locked) askPassword = true else onJoin(null) if (channel.locked) askPassword = true else onJoin(null)
} }
} }
if (channel.subscribed) { if (channel.subscribed) {
Action(Icons.Filled.VisibilityOff, "Unsubscribe") { onSubscribe(false) } Action("UNSUBSCRIBE_FROM_CHANNEL", "Unsubscribe") { onSubscribe(false) }
} else { } else {
Action(Icons.Filled.Visibility, "Subscribe") { onSubscribe(true) } Action("SUBSCRIBE_TO_CHANNEL", "Subscribe") { onSubscribe(true) }
} }
} }
} }
@@ -134,10 +126,10 @@ fun ChannelSheet(
} }
@Composable @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( ListItem(
headlineContent = { Text(label) }, headlineContent = { Text(label) },
leadingContent = { Icon(icon, null) }, leadingContent = { PackIcon(iconKey, null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 8.dp), modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 8.dp),
) )

View File

@@ -7,6 +7,7 @@ lifecycle = "2.11.0"
bouncycastle = "1.86" bouncycastle = "1.86"
ini4j = "0.5.1" ini4j = "0.5.1"
oboe = "1.11.0" oboe = "1.11.0"
androidsvg = "1.4"
[libraries] [libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } 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-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", 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" } bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" }
ini4j = { module = "org.ini4j:ini4j", version.ref = "ini4j" } ini4j = { module = "org.ini4j:ini4j", version.ref = "ini4j" }
oboe = { module = "com.google.oboe:oboe", version.ref = "oboe" } oboe = { module = "com.google.oboe:oboe", version.ref = "oboe" }

View File

@@ -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 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`, your choosing, so the official packs (`default_colored_2014.zip`,
`default_mono_2014.zip`, the legacy `default.zip`) work unchanged. `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 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 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 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 ├── contacts friends and blocked clients, in TS3's own record format
├── text BBCode, TeamSpeak links, chat search ├── text BBCode, TeamSpeak links, chat search
├── gfx ├── gfx
│ ├── IconPack an icon pack zip/folder + its settings.ini mapping │ ├── IconPack an icon pack zip/folder/classpath folder + its settings.ini mapping
│ └── IconPacks discovery of installed packs │ ├── IconPacks discovery of installed packs, the shipped Material one, lookup order
│ └── IconKeys the icon keys the channel tree asks for
├── hotkey ├── hotkey
│ ├── HotkeyAction catalogue of TeamSpeak's hotkey actions (RE'd from its binary) │ ├── HotkeyAction catalogue of TeamSpeak's hotkey actions (RE'd from its binary)
│ ├── Hotkey/HotkeyCombo one binding: keys, trigger edge, server scope, argument │ ├── Hotkey/HotkeyCombo one binding: keys, trigger edge, server scope, argument

View File

@@ -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";
};
}
}

View File

@@ -39,7 +39,8 @@ import java.util.zip.ZipFile;
* resolved by convention, picking the size closest to the one that is asked for. * resolved by convention, picking the size closest to the one that is asked for.
* *
* <p>Icon data is returned as bytes; rasterising it (the packs from 2014 on are * <p>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 { public final class IconPack {
@@ -51,20 +52,26 @@ public final class IconPack {
private record Entry(String path, int size) { 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 String id;
private final File source; private final File source;
private final String name; private final String name;
private final boolean zipped; private final Storage storage;
private final boolean fallback; private final boolean fallback;
/** Icon key to the images that can serve it, smallest first. */ /** Icon key to the images that can serve it, smallest first. */
private final Map<String, List<Entry>> entries; private final Map<String, List<Entry>> 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<String, List<Entry>> entries) { Map<String, List<Entry>> entries) {
this.id = id; this.id = id;
this.source = source; this.source = source;
this.name = name; this.name = name;
this.zipped = zipped; this.storage = storage;
this.fallback = fallback; this.fallback = fallback;
this.entries = entries; this.entries = entries;
} }
@@ -78,6 +85,7 @@ public final class IconPack {
return name; return name;
} }
/** @return the pack's zip or directory, or {@code null} for one shipped with the client */
public File source() { public File source() {
return source; return source;
} }
@@ -108,7 +116,7 @@ public final class IconPack {
Entry entry = select(entries.get(normalizeKey(key)), preferredSize); Entry entry = select(entries.get(normalizeKey(key)), preferredSize);
if (entry == null) return null; if (entry == null) return null;
try { try {
return zipped ? readFromZip(entry.path()) : Files.readAllBytes(new File(source, entry.path()).toPath()); return storage.read(entry.path());
} catch (IOException e) { } catch (IOException e) {
return null; return null;
} }
@@ -134,14 +142,16 @@ public final class IconPack {
return candidates.get(candidates.size() - 1); return candidates.get(candidates.size() - 1);
} }
private byte[] readFromZip(String path) throws IOException { /**
try (ZipFile zip = new ZipFile(source)) { * Sets the colour vector art paints with {@code currentColor}, as a stylesheet's
ZipEntry entry = zip.getEntry(path); * {@code color} would.
if (entry == null) return null; *
try (InputStream in = zip.getInputStream(entry)) { * @param rgb the colour as {@code 0xRRGGBB}
return in.readAllBytes(); */
} 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) { private static String normalizeKey(String key) {
@@ -162,6 +172,7 @@ public final class IconPack {
Map<String, Long> files = zipped ? listZip(source) : listDirectory(source); Map<String, Long> files = zipped ? listZip(source) : listDirectory(source);
if (files == null || files.isEmpty()) return null; if (files == null || files.isEmpty()) return null;
Storage storage = zipped ? path -> readFromZip(source, path) : path -> readFromDirectory(source, path);
String iniPath = files.keySet().stream() String iniPath = files.keySet().stream()
.filter(p -> p.equalsIgnoreCase(INI)) .filter(p -> p.equalsIgnoreCase(INI))
@@ -170,7 +181,7 @@ public final class IconPack {
Map<String, String> info = new LinkedHashMap<>(); Map<String, String> info = new LinkedHashMap<>();
boolean fallback = true; boolean fallback = true;
if (iniPath != null) { if (iniPath != null) {
byte[] ini = read(source, zipped, iniPath); byte[] ini = read(storage, iniPath);
if (ini != null) fallback = parseIni(ini, mapping, info); if (ini != null) fallback = parseIni(ini, mapping, info);
} }
@@ -181,7 +192,35 @@ public final class IconPack {
String id = source.getName(); String id = source.getName();
String name = info.getOrDefault("name", displayName(id)); 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<String, String> mapping = new LinkedHashMap<>();
Map<String, String> info = new LinkedHashMap<>();
boolean fallback = parseIni(ini, mapping, info);
Map<String, List<Entry>> entries = new LinkedHashMap<>();
for (Map.Entry<String, String> 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}. */ /** Turns {@code default_colored_2014.zip} into {@code Default Colored 2014}. */
@@ -278,21 +317,29 @@ public final class IconPack {
return null; return null;
} }
private static byte[] read(File source, boolean zipped, String path) { private static byte[] read(Storage storage, String path) {
try { try {
if (!zipped) return Files.readAllBytes(new File(source, path).toPath()); return storage.read(path);
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();
}
}
} catch (IOException e) { } catch (IOException e) {
return null; 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<String, Long> listZip(File file) { private static Map<String, Long> listZip(File file) {
if (!file.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) return null; if (!file.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) return null;
Map<String, Long> files = new LinkedHashMap<>(); Map<String, Long> files = new LinkedHashMap<>();

View File

@@ -14,12 +14,16 @@ import java.util.Map;
* Finds the icon packs installed on this machine: the client's own {@code gfx/} * 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 * 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 * 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 { public final class IconPacks {
/** The pack every other one falls back to, as TeamSpeak does. */ /** The pack every other one falls back to, as TeamSpeak does. */
public static final String DEFAULT_ID = "default.zip"; 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() { private IconPacks() {
} }
@@ -44,11 +48,19 @@ public final class IconPacks {
if (pack != null) byId.put(pack.id(), pack); if (pack != null) byId.put(pack.id(), pack);
} }
} }
IconPack shipped = material();
if (shipped != null) byId.putIfAbsent(shipped.id(), shipped);
List<IconPack> packs = new ArrayList<>(byId.values()); List<IconPack> packs = new ArrayList<>(byId.values());
packs.sort(Comparator.comparing(p -> p.name().toLowerCase(Locale.ROOT))); packs.sort(Comparator.comparing(p -> p.name().toLowerCase(Locale.ROOT)));
return packs; 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 */ /** @return the pack with this id, or {@code null} when it is not installed */
public static IconPack select(List<IconPack> packs, String id) { public static IconPack select(List<IconPack> packs, String id) {
for (IconPack p : packs) { for (IconPack p : packs) {
@@ -57,8 +69,19 @@ public final class IconPacks {
return null; return null;
} }
/** @return the pack unresolved icons are taken from, or {@code null} */ /**
public static IconPack fallback(List<IconPack> packs) { * The packs to look an icon up in, in turn: the active one, TeamSpeak's
return select(packs, DEFAULT_ID); * {@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<IconPack> lookupOrder(IconPack active, List<IconPack> packs, IconPack lastResort) {
List<IconPack> 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;
} }
} }

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>

After

Width:  |  Height:  |  Size: 157 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M3 4V1h2v3h3v2H5v3H3V6H0V4h3zm3 6V7h3V4h7l1.83 2H21c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V10h3zm7 9c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-3.2-5c0 1.77 1.43 3.2 3.2 3.2s3.2-1.43 3.2-3.2-1.43-3.2-3.2-3.2-3.2 1.43-3.2 3.2z"/></svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M22 4c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4V4zm-2 13.17L18.83 16H4V4h16v13.17zM13 5h-2v4H7v2h4v4h2v-4h4V9h-4z"/></svg>

After

Width:  |  Height:  |  Size: 250 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20 7h-5V4c0-1.1-.9-2-2-2h-2c-1.1 0-2 .9-2 2v3H4c-1.1 0-2 .9-2 2v11c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2zM9 12c.83 0 1.5.67 1.5 1.5S9.83 15 9 15s-1.5-.67-1.5-1.5S8.17 12 9 12zm3 6H6v-.75c0-1 2-1.5 3-1.5s3 .5 3 1.5V18zm1-9h-2V4h2v5zm5 7.5h-4V15h4v1.5zm0-3h-4V12h4v1.5z"/></svg>

After

Width:  |  Height:  |  Size: 401 B

View File

@@ -0,0 +1 @@
<svg fill="#9E8A2E" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12.34 2.02C6.59 1.82 2 6.42 2 12c0 5.52 4.48 10 10 10 3.71 0 6.93-2.02 8.66-5.02-7.51-.25-12.09-8.43-8.32-14.96z"/></svg>

After

Width:  |  Height:  |  Size: 230 B

View File

@@ -0,0 +1 @@
<svg fill="#D9443A" fill-opacity="0.5" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9A7.902 7.902 0 0 1 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1A7.902 7.902 0 0 1 20 12c0 4.42-3.58 8-8 8z"/></svg>

After

Width:  |  Height:  |  Size: 366 B

View File

@@ -0,0 +1 @@
<svg fill="#D9443A" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9A7.902 7.902 0 0 1 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1A7.902 7.902 0 0 1 20 12c0 4.42-3.58 8-8 8z"/></svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M17 3H7c-1.1 0-1.99.9-1.99 2L5 21l7-3 7 3V5c0-1.1-.9-2-2-2z"/></svg>

After

Width:  |  Height:  |  Size: 181 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M21 7h-2v2h-2V7h-2V5h2V3h2v2h2v2zm-2 14-7-3-7 3V5c0-1.1.9-2 2-2h7a5.002 5.002 0 0 0 5 7.9V21z"/></svg>

After

Width:  |  Height:  |  Size: 215 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M21 7h-6V5h6v2zm-2 3.9A5.002 5.002 0 0 1 14 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V10.9z"/></svg>

After

Width:  |  Height:  |  Size: 202 B

View File

@@ -0,0 +1 @@
<svg fill="#2E9BF0" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M18 11v2h4v-2h-4zm-2 6.61c.96.71 2.21 1.65 3.2 2.39.4-.53.8-1.07 1.2-1.6-.99-.74-2.24-1.68-3.2-2.4-.4.54-.8 1.08-1.2 1.61zM20.4 5.6c-.4-.53-.8-1.07-1.2-1.6-.99.74-2.24 1.68-3.2 2.4.4.53.8 1.07 1.2 1.6.96-.72 2.21-1.65 3.2-2.4zM4 9c-1.1 0-2 .9-2 2v2c0 1.1.9 2 2 2h1v4h2v-4h1l5 3V6L8 9H4zm11.5 3c0-1.33-.58-2.53-1.5-3.35v6.69c.92-.81 1.5-2.01 1.5-3.34z"/></svg>

After

Width:  |  Height:  |  Size: 467 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M18 11v2h4v-2h-4zm-2 6.61c.96.71 2.21 1.65 3.2 2.39.4-.53.8-1.07 1.2-1.6-.99-.74-2.24-1.68-3.2-2.4-.4.54-.8 1.08-1.2 1.61zM20.4 5.6c-.4-.53-.8-1.07-1.2-1.6-.99.74-2.24 1.68-3.2 2.4.4.53.8 1.07 1.2 1.6.96-.72 2.21-1.65 3.2-2.4zM4 9c-1.1 0-2 .9-2 2v2c0 1.1.9 2 2 2h1v4h2v-4h1l5 3V6L8 9H4zm5.03 1.71L11 9.53v4.94l-1.97-1.18-.48-.29H4v-2h4.55l.48-.29zM15.5 12c0-1.33-.58-2.53-1.5-3.35v6.69c.92-.81 1.5-2.01 1.5-3.34z"/></svg>

After

Width:  |  Height:  |  Size: 534 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-opacity="0.5" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M4 4h16v12H5.17L4 17.17V4m0-2c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2H4zm2 10h8v2H6v-2zm0-3h12v2H6V9zm0-3h12v2H6V6z"/></svg>

After

Width:  |  Height:  |  Size: 281 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M4 4h16v12H5.17L4 17.17V4m0-2c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2H4zm2 10h8v2H6v-2zm0-3h12v2H6V9zm0-3h12v2H6V6z"/></svg>

After

Width:  |  Height:  |  Size: 262 B

View File

@@ -0,0 +1 @@
<svg fill="#2E9BF0" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2z"/></svg>

After

Width:  |  Height:  |  Size: 183 B

View File

@@ -0,0 +1 @@
<svg fill="#888888" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg>

After

Width:  |  Height:  |  Size: 241 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>

After

Width:  |  Height:  |  Size: 223 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20 0H4v2h16V0zM4 24h16v-2H4v2zM20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-8 2.75c1.24 0 2.25 1.01 2.25 2.25s-1.01 2.25-2.25 2.25S9.75 10.24 9.75 9 10.76 6.75 12 6.75zM17 17H7v-1.5c0-1.67 3.33-2.5 5-2.5s5 .83 5 2.5V17z"/></svg>

After

Width:  |  Height:  |  Size: 374 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20 6h-8l-2-2H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2zm-1 8h-3v3h-2v-3h-3v-2h3V9h2v3h3v2z"/></svg>

After

Width:  |  Height:  |  Size: 256 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>

After

Width:  |  Height:  |  Size: 199 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20 13H4c-.55 0-1 .45-1 1v6c0 .55.45 1 1 1h16c.55 0 1-.45 1-1v-6c0-.55-.45-1-1-1zM7 19c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM20 3H4c-.55 0-1 .45-1 1v6c0 .55.45 1 1 1h16c.55 0 1-.45 1-1V4c0-.55-.45-1-1-1zM7 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>

After

Width:  |  Height:  |  Size: 379 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z"/></svg>

After

Width:  |  Height:  |  Size: 163 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M18.41 5.8 17.2 4.59c-.78-.78-2.05-.78-2.83 0l-2.68 2.68L3 15.96V20h4.04l8.74-8.74 2.63-2.63c.79-.78.79-2.05 0-2.83zM6.21 18H5v-1.21l8.66-8.66 1.21 1.21L6.21 18zM11 20l4-4h6v4H11z"/></svg>

After

Width:  |  Height:  |  Size: 301 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10zM8 13.01l1.41 1.41L11 12.84V17h2v-4.16l1.59 1.59L16 13.01 12.01 9 8 13.01z"/></svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a.996.996 0 0 0 0-1.41l-2.34-2.34a.996.996 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>

After

Width:  |  Height:  |  Size: 267 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>

After

Width:  |  Height:  |  Size: 218 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>

After

Width:  |  Height:  |  Size: 213 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="m5.25 8.069 2.83-2.827 14.134 14.15-2.83 2.827zm4.236-4.242L12.314.998l5.657 5.656-2.828 2.83zM.999 12.315l2.828-2.829 5.657 5.657-2.828 2.828zM1 21h12v2H1z"/></svg>

After

Width:  |  Height:  |  Size: 278 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/><circle cx="8" cy="14" r="2"/><circle cx="12" cy="8" r="2"/><circle cx="16" cy="14" r="2"/></svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73V18H6v-1.61c0-1.18.68-2.26 1.76-2.73 1.17-.52 2.61-.91 4.24-.91zM4 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58A2.01 2.01 0 0 0 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4 3.43c0-.81-.48-1.53-1.22-1.85A6.95 6.95 0 0 0 20 14c-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57zM12 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z"/></svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -0,0 +1 @@
<svg fill="#888888" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 4c3.87 0 7 3.13 7 7v2h-2.92L21 17.92V11a9 9 0 0 0-9-9c-1.95 0-3.76.62-5.23 1.68l1.44 1.44A6.914 6.914 0 0 1 12 4zM2.27 1.72 1 3l3.33 3.32A8.899 8.899 0 0 0 3 11v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-1.17.29-2.26.79-3.22L15 17v4h3c.3 0 .59-.06.86-.14L21 23l1.27-1.27-20-20.01z"/></svg>

After

Width:  |  Height:  |  Size: 390 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M4 11h16v2H4z"/></svg>

After

Width:  |  Height:  |  Size: 155 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>

After

Width:  |  Height:  |  Size: 218 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>

After

Width:  |  Height:  |  Size: 208 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-opacity="0.5" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>

After

Width:  |  Height:  |  Size: 366 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M11 7 9.6 8.4l2.6 2.6H2v2h10.2l-2.6 2.6L11 17l5-5-5-5zm9 12h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-8v2h8v14z"/></svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="m17 7-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/></svg>

After

Width:  |  Height:  |  Size: 313 B

View File

@@ -0,0 +1 @@
<svg fill="#888888" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3 3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"/></svg>

After

Width:  |  Height:  |  Size: 473 B

View File

@@ -0,0 +1 @@
<svg fill="#9E8A2E" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3 3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"/></svg>

After

Width:  |  Height:  |  Size: 473 B

View File

@@ -0,0 +1 @@
<svg fill="#D9443A" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3 3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"/></svg>

After

Width:  |  Height:  |  Size: 473 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M3 11c0 2.45 1.76 4.47 4.08 4.91l-1.49-1.49L7 13l4 4.01L7 21l-1.41-1.41 1.58-1.58v-.06A7.007 7.007 0 0 1 1 11c0-3.87 3.13-7 7-7h3v2H8c-2.76 0-5 2.24-5 5zm19 0V4h-9v7h9zm-2-2h-5V6h5v3zm-7 4h9v7h-9z"/></svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10.94 8.12 7.48 4.66 9 3h6l1.83 2H20c1.1 0 2 .9 2 2v12c0 .05-.01.1-.02.16l-5.1-5.1c.08-.35.12-.7.12-1.06 0-2.76-2.24-5-5-5-.36 0-.71.04-1.06.12zm9.55 15.19L18.17 21H4c-1.1 0-2-.9-2-2V7c0-.59.27-1.12.68-1.49l-2-2L2.1 2.1l19.8 19.8-1.41 1.41zm-6-5.99-1.5-1.5c-.32.1-.64.18-.99.18-1.66 0-3-1.34-3-3 0-.35.08-.67.19-.98l-1.5-1.5A4.74 4.74 0 0 0 7 13c0 2.76 2.24 5 5 5 .91 0 1.76-.25 2.49-.68z"/></svg>

After

Width:  |  Height:  |  Size: 511 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.58 4.08 6.15 2.65C3.75 4.48 2.17 7.3 2.03 10.5h2a8.445 8.445 0 0 1 3.55-6.42zm12.39 6.42h2c-.15-3.2-1.73-6.02-4.12-7.85l-1.42 1.43a8.495 8.495 0 0 1 3.54 6.42zM18 11c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2v-5zm-6 11c.14 0 .27-.01.4-.04.65-.14 1.18-.58 1.44-1.18.1-.24.15-.5.15-.78h-4c.01 1.1.9 2 2.01 2z"/></svg>

After

Width:  |  Height:  |  Size: 500 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>

After

Width:  |  Height:  |  Size: 263 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M8.65 5.82a3.999 3.999 0 1 1 5.53 5.53L8.65 5.82zM20 17.17c-.02-1.1-.63-2.11-1.61-2.62-.54-.28-1.13-.54-1.77-.76L20 17.17zm1.19 4.02L2.81 2.81 1.39 4.22l8.89 8.89c-1.81.23-3.39.79-4.67 1.45A2.97 2.97 0 0 0 4 17.22V20h13.17l2.61 2.61 1.41-1.42z"/></svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M14 8c0-2.21-1.79-4-4-4S6 5.79 6 8s1.79 4 4 4 4-1.79 4-4zm-2 0c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM2 18v2h16v-2c0-2.66-5.33-4-8-4s-8 1.34-8 4zm2 0c.2-.71 3.3-2 6-2 2.69 0 5.77 1.28 6 2H4zm13-8h6v2h-6z"/></svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M14 8c0-2.21-1.79-4-4-4S6 5.79 6 8s1.79 4 4 4 4-1.79 4-4zm3 2v2h6v-2h-6zM2 18v2h16v-2c0-2.66-5.33-4-8-4s-8 1.34-8 4z"/></svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M13 3h-2v10h2V3zm4.83 2.17-1.42 1.42A6.92 6.92 0 0 1 19 12c0 3.87-3.13 7-7 7A6.995 6.995 0 0 1 7.58 6.58L6.17 5.17A8.932 8.932 0 0 0 3 12a9 9 0 0 0 18 0c0-2.74-1.23-5.18-3.17-6.83z"/></svg>

After

Width:  |  Height:  |  Size: 302 B

View File

@@ -0,0 +1 @@
<svg fill="#2E9BF0" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="9" cy="9" r="4"/><path d="M9 15c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4zm7.76-9.64-1.68 1.69c.84 1.18.84 2.71 0 3.89l1.68 1.69c2.02-2.02 2.02-5.07 0-7.27zM20.07 2l-1.63 1.63c2.77 3.02 2.77 7.56 0 10.74L20.07 16c3.9-3.89 3.91-9.95 0-14z"/></svg>

After

Width:  |  Height:  |  Size: 360 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11H7v-2h10v2z"/></svg>

After

Width:  |  Height:  |  Size: 204 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7 11v2h10v-2H7zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>

After

Width:  |  Height:  |  Size: 261 B

View File

@@ -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

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>

After

Width:  |  Height:  |  Size: 814 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>

After

Width:  |  Height:  |  Size: 210 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="m19 15-6 6-1.42-1.42L15.17 16H4V4h2v10h9.17l-3.59-3.58L13 9l6 6z"/></svg>

After

Width:  |  Height:  |  Size: 186 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M16 17.01V10h-2v7.01h-3L15 21l4-3.99h-3zM9 3 5 6.99h3V14h2V6.99h3L9 3z"/></svg>

After

Width:  |  Height:  |  Size: 192 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16c1.1 0 2-.9 2-2V6a2 2 0 0 0-2-2zm0 14H4V8h16v10zm-2-1h-6v-2h6v2zM7.5 17l-1.41-1.41L8.67 13l-2.59-2.59L7.5 9l4 4-4 4z"/></svg>

After

Width:  |  Height:  |  Size: 279 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M5 20h14v-2H5v2zm0-10h4v6h6v-6h4l-7-7-7 7z"/></svg>

After

Width:  |  Height:  |  Size: 164 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 6a9.77 9.77 0 0 1 8.82 5.5C19.17 14.87 15.79 17 12 17s-7.17-2.13-8.82-5.5A9.77 9.77 0 0 1 12 6m0-2C7 4 2.73 7.11 1 11.5 2.73 15.89 7 19 12 19s9.27-3.11 11-7.5C21.27 7.11 17 4 12 4zm0 5a2.5 2.5 0 0 1 0 5 2.5 2.5 0 0 1 0-5m0-2c-2.48 0-4.5 2.02-4.5 4.5S9.52 16 12 16s4.5-2.02 4.5-4.5S14.48 7 12 7z"/></svg>

After

Width:  |  Height:  |  Size: 420 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 6a9.77 9.77 0 0 1 8.82 5.5 9.647 9.647 0 0 1-2.41 3.12l1.41 1.41c1.39-1.23 2.49-2.77 3.18-4.53C21.27 7.11 17 4 12 4c-1.27 0-2.49.2-3.64.57l1.65 1.65C10.66 6.09 11.32 6 12 6zm-1.07 1.14L13 9.21c.57.25 1.03.71 1.28 1.28l2.07 2.07c.08-.34.14-.7.14-1.07C16.5 9.01 14.48 7 12 7c-.37 0-.72.05-1.07.14zM2.01 3.87l2.68 2.68A11.738 11.738 0 0 0 1 11.5C2.73 15.89 7 19 12 19c1.52 0 2.98-.29 4.32-.82l3.42 3.42 1.41-1.41L3.42 2.45 2.01 3.87zm7.5 7.5 2.61 2.61c-.04.01-.08.02-.12.02a2.5 2.5 0 0 1-2.5-2.5c0-.05.01-.08.01-.13zm-3.4-3.4 1.75 1.75a4.6 4.6 0 0 0-.36 1.78 4.507 4.507 0 0 0 6.27 4.14l.98.98c-.88.24-1.8.38-2.75.38a9.77 9.77 0 0 1-8.82-5.5c.7-1.43 1.72-2.61 2.93-3.53z"/></svg>

After

Width:  |  Height:  |  Size: 793 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46A11.804 11.804 0 0 0 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>

After

Width:  |  Height:  |  Size: 627 B

View File

@@ -0,0 +1 @@
<svg fill="#D9443A" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M16.5 12A4.5 4.5 0 0 0 14 7.97v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.796 8.796 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 0 0 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"/></svg>

After

Width:  |  Height:  |  Size: 460 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>

After

Width:  |  Height:  |  Size: 305 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>

After

Width:  |  Height:  |  Size: 172 B

View File

@@ -1,5 +1,6 @@
package com.ts3client.gfx; package com.ts3client.gfx;
import com.ts3client.net.ClientStatus;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; 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(Files.createDirectory(dir.resolve("empty")).toFile()));
assertNull(IconPack.load(null)); 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 = "<svg fill=\"currentColor\"><path fill=\"#123456\"/></svg>".getBytes(StandardCharsets.UTF_8);
assertEquals("<svg fill=\"#0A0B0C\"><path fill=\"#123456\"/></svg>",
new String(IconPack.withCurrentColor(svg, 0xFF0A0B0C), StandardCharsets.UTF_8));
byte[] fixed = "<svg/>".getBytes(StandardCharsets.UTF_8);
assertTrue(fixed == IconPack.withCurrentColor(fixed, 0));
}
} }

View File

@@ -43,7 +43,7 @@ final class DesignPanel extends JPanel {
private static final int TILE = 44; private static final int TILE = 44;
/** Stands for "no pack": the icons the client draws itself. */ /** 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). */ /** 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}; private static final double[] SCALES = {0, 1, 1.25, 1.5, 1.75, 2, 2.5, 3};

View File

@@ -12,6 +12,8 @@ import com.ts3client.gfx.IconPacks;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import javax.swing.ImageIcon; import javax.swing.ImageIcon;
import javax.swing.UIManager;
import java.awt.Color;
import java.awt.Graphics2D; import java.awt.Graphics2D;
import java.awt.Image; import java.awt.Image;
import java.awt.RenderingHints; import java.awt.RenderingHints;
@@ -34,7 +36,8 @@ import java.util.concurrent.CopyOnWriteArrayList;
* instead, so the client always has a complete set. * instead, so the client always has a complete set.
* *
* <p>Rendering is cached per key and size: vector art is rasterised at the size it * <p>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. * {@link #SIZE} and {@link #TOOLBAR_SIZE} are unscaled; sizes passed in are pixels.
*/ */
public final class IconTheme { public final class IconTheme {
@@ -51,7 +54,8 @@ public final class IconTheme {
private volatile List<IconPack> packs = List.of(); private volatile List<IconPack> packs = List.of();
private volatile IconPack active; 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<IconPack> lookupOrder = List.of();
private IconTheme() { private IconTheme() {
} }
@@ -72,14 +76,23 @@ public final class IconTheme {
/** Re-scans the pack folders and activates the one the settings name. */ /** Re-scans the pack folders and activates the one the settings name. */
public void reload(Settings settings) { public void reload(Settings settings) {
packs = IconPacks.findAll(new File(AppDirs.profile(), "gfx"), settings.iconPackDir); packs = IconPacks.findAll(new File(AppDirs.profile(), "gfx"), settings.iconPackDir);
fallback = IconPacks.fallback(packs); IconPack pack = IconPacks.select(packs, settings.iconPack);
setPack(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). */ /** Switches to a pack (which may be {@code null} for the built-in icons). */
public void setPack(IconPack pack) { public void setPack(IconPack pack) {
if (active == pack) return; if (active == pack) return;
active = pack; 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(); cache.clear();
for (Runnable listener : listeners) listener.run(); for (Runnable listener : listeners) listener.run();
} }
@@ -105,22 +118,29 @@ public final class IconTheme {
ImageIcon cached = cache.get(cacheKey); ImageIcon cached = cache.get(cacheKey);
if (cached != null) return cached; if (cached != null) return cached;
ImageIcon icon = render(active, key, size); ImageIcon icon = null;
if (icon == null && active != null && active.fallsBack() && fallback != active) { for (IconPack pack : lookupOrder) {
icon = render(fallback, key, size); icon = render(pack, key, size);
if (icon != null) break;
} }
if (icon != null) cache.put(cacheKey, icon); if (icon != null) cache.put(cacheKey, icon);
return icon; return icon;
} }
private static ImageIcon render(IconPack pack, String key, int size) { private static ImageIcon render(IconPack pack, String key, int size) {
if (pack == null) return null;
byte[] data = pack.icon(key, size); byte[] data = pack.icon(key, size);
if (data == null || data.length == 0) return null; 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); 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) { private static BufferedImage rasterize(byte[] data, int size) {
try { try {
SVGDocument document = new SVGLoader().load( SVGDocument document = new SVGLoader().load(

View File

@@ -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.gfx.IconKeys;
import javax.swing.ImageIcon; import javax.swing.ImageIcon;
import java.awt.BasicStroke; import java.awt.BasicStroke;
@@ -73,10 +74,7 @@ public final class Icons {
return icon != null ? icon : make(size, fallback); return icon != null ? icon : make(size, fallback);
} }
/** /** Like {@link #themed(String, Painter)}, but tries the keys in turn. */
* 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).
*/
private static ImageIcon themed(String[] keys, Painter fallback) { private static ImageIcon themed(String[] keys, Painter fallback) {
for (String key : keys) { for (String key : keys) {
ImageIcon icon = IconTheme.icon(key, small()); ImageIcon icon = IconTheme.icon(key, small());
@@ -108,27 +106,22 @@ public final class Icons {
} }
public static ImageIcon channel(boolean subscribed) { 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)); g -> paintChannel(g, new Color(0x3E7CB1), false, subscribed));
} }
/** A channel nobody can join without its password. */ /** A channel nobody can join without its password. */
public static ImageIcon channelLocked(boolean subscribed) { 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)); g -> paintChannel(g, new Color(0x8A6D3B), true, subscribed));
} }
/** A channel that has reached its client limit. */ /** A channel that has reached its client limit. */
public static ImageIcon channelFull(boolean subscribed) { 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)); 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. */ /** The chat tab for the current channel. */
public static ImageIcon channelChat() { public static ImageIcon channelChat() {
ImageIcon icon = of("CHANNEL_CHAT"); ImageIcon icon = of("CHANNEL_CHAT");

View File

@@ -43,6 +43,7 @@ public final class LookAndFeelManager {
*/ */
public static void switchTo(Settings.Appearance appearance) { public static void switchTo(Settings.Appearance appearance) {
install(appearance); install(appearance);
IconTheme.get().refresh();
FlatLaf.updateUI(); FlatLaf.updateUI();
for (Window w : Window.getWindows()) { for (Window w : Window.getWindows()) {
w.repaint(); w.repaint();

View File

@@ -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("<svg ", "<svg" + attributes + " ", 1)
def main():
OUT.mkdir(parents=True, exist_ok=True)
for old in OUT.glob("*.svg"):
old.unlink()
lines = []
width = max(map(len, ICONS))
for key, spec in ICONS.items():
icon, style, colour, opacity = spec + ("", None, None)[len(spec) - 1:]
name = file_name(icon, style, colour, opacity)
path = OUT / name
if not path.exists():
path.write_text(paint(fetch(icon, style), colour, opacity) + "\n")
lines.append(f"{key.ljust(width)} = {name}")
(OUT / "settings.ini").write_text(INI_HEAD + "\n".join(lines) + "\n")
if __name__ == "__main__":
main()