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>
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
139
android/app/src/main/java/com/ts3client/android/IconTheme.kt
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,11 +55,13 @@ fun App(vm: SessionController) {
|
|||||||
else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO))
|
else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalIconTheme provides vm.icons) {
|
||||||
when {
|
when {
|
||||||
settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false })
|
settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false })
|
||||||
state.busy || state.connected -> ServerScreen(vm, snackbar, { settingsOpen = true }, enableMicrophone)
|
state.busy || state.connected -> ServerScreen(vm, snackbar, { settingsOpen = true }, enableMicrophone)
|
||||||
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking)
|
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
poke?.let { p ->
|
poke?.let { p ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,19 +317,27 @@ 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)) {
|
} catch (IOException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readFromZip(File zipFile, String path) throws IOException {
|
||||||
|
try (ZipFile zip = new ZipFile(zipFile)) {
|
||||||
ZipEntry entry = zip.getEntry(path);
|
ZipEntry entry = zip.getEntry(path);
|
||||||
if (entry == null) return null;
|
if (entry == null) return null;
|
||||||
try (InputStream in = zip.getInputStream(entry)) {
|
try (InputStream in = zip.getInputStream(entry)) {
|
||||||
return in.readAllBytes();
|
return in.readAllBytes();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
ts3-client/core/src/main/resources/gfx/material/add.svg
Normal 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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
1
ts3-client/core/src/main/resources/gfx/material/dns.svg
Normal 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 |
@@ -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 |
@@ -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 |
@@ -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 |
1
ts3-client/core/src/main/resources/gfx/material/edit.svg
Normal 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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
1
ts3-client/core/src/main/resources/gfx/material/info.svg
Normal 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 |
1
ts3-client/core/src/main/resources/gfx/material/list.svg
Normal 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 |
@@ -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 |
1
ts3-client/core/src/main/resources/gfx/material/lock.svg
Normal 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 |
@@ -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 |
@@ -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 |
1
ts3-client/core/src/main/resources/gfx/material/mic.svg
Normal 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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
90
ts3-client/core/src/main/resources/gfx/material/settings.ini
Normal 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
|
||||||
@@ -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 |
1
ts3-client/core/src/main/resources/gfx/material/star.svg
Normal 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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
165
ts3-client/tools/material-icon-pack.py
Normal 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()
|
||||||