Share icon packs between desktop and Android
The Android app's Material icons become a TeamSpeak-format pack shipped in core, which the desktop offers next to the TeamSpeak packs. Android now draws its icons from packs too and imports TeamSpeak's own zips, falling back to the Material pack for what they lack. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,4 +54,5 @@ dependencies {
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.service)
|
||||
implementation(libs.androidsvg)
|
||||
}
|
||||
|
||||
139
android/app/src/main/java/com/ts3client/android/IconTheme.kt
Normal file
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) {
|
||||
|
||||
val settings: Settings = Settings.load()
|
||||
val icons = IconTheme(context, settings)
|
||||
private val bookmarkStore = Bookmarks.load()
|
||||
private val identities = IdentityStore.load(settings)
|
||||
private val audio = AndroidAudioBackend(context)
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -54,10 +55,12 @@ fun App(vm: SessionController) {
|
||||
else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO))
|
||||
}
|
||||
|
||||
when {
|
||||
settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false })
|
||||
state.busy || state.connected -> ServerScreen(vm, snackbar, { settingsOpen = true }, enableMicrophone)
|
||||
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking)
|
||||
CompositionLocalProvider(LocalIconTheme provides vm.icons) {
|
||||
when {
|
||||
settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false })
|
||||
state.busy || state.connected -> ServerScreen(vm, snackbar, { settingsOpen = true }, enableMicrophone)
|
||||
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking)
|
||||
}
|
||||
}
|
||||
|
||||
poke?.let { p ->
|
||||
|
||||
@@ -15,33 +15,17 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.Chat
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material.icons.filled.Block
|
||||
import androidx.compose.material.icons.filled.Campaign
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.HeadsetOff
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.filled.VolumeOff
|
||||
import androidx.compose.material.icons.outlined.Campaign
|
||||
import androidx.compose.material.icons.outlined.Circle
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -50,6 +34,7 @@ import com.ts3client.android.ChannelItem
|
||||
import com.ts3client.android.ClientItem
|
||||
import com.ts3client.android.ServerTree
|
||||
import com.ts3client.contacts.ContactType
|
||||
import com.ts3client.gfx.IconKeys
|
||||
import com.ts3client.net.ClientStatus
|
||||
|
||||
/** One line of the tree as the list shows it. */
|
||||
@@ -95,7 +80,7 @@ fun ChannelTree(
|
||||
LazyColumn(modifier.fillMaxSize()) {
|
||||
item(key = "server") {
|
||||
RowLayout(depth = 0, onClick = null) {
|
||||
Icon(Icons.Filled.Dns, null, Modifier.size(ICON))
|
||||
PackIcon("SERVER_GREEN", null, size = ICON)
|
||||
Text(serverName, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
@@ -138,13 +123,7 @@ private fun ChannelRow(row: TreeRow.Channel, onToggle: (ChannelItem) -> Unit, on
|
||||
if (spacer != null) {
|
||||
SpacerLabel(spacer.align(), spacer.caption().orEmpty(), Modifier.weight(1f))
|
||||
} else {
|
||||
val icon = when {
|
||||
channel.locked -> Icons.Filled.Lock
|
||||
channel.full -> Icons.Filled.Block
|
||||
else -> Icons.AutoMirrored.Outlined.Chat
|
||||
}
|
||||
Icon(icon, null, Modifier.size(ICON).alpha(if (channel.subscribed) 1f else 0.5f),
|
||||
tint = if (channel.full && !channel.locked) StatusColors.muted else LocalContentColor.current)
|
||||
PackIcon(IconKeys.channel(channel.locked, channel.full, channel.subscribed).asList(), null, size = ICON)
|
||||
Text(
|
||||
channel.name,
|
||||
Modifier.weight(1f),
|
||||
@@ -203,19 +182,5 @@ private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) {
|
||||
|
||||
@Composable
|
||||
fun StatusIcon(status: ClientStatus, modifier: Modifier = Modifier) {
|
||||
val (icon, tint) = statusIcon(status)
|
||||
Icon(icon, status.name, modifier.size(ICON), tint = if (tint == Color.Unspecified) LocalContentColor.current else tint)
|
||||
}
|
||||
|
||||
private fun statusIcon(status: ClientStatus): Pair<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
|
||||
PackIcon(IconKeys.client(status), status.name, modifier, size = ICON)
|
||||
}
|
||||
|
||||
@@ -11,13 +11,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -61,7 +56,7 @@ fun ConnectScreen(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("TeamSpeak") },
|
||||
actions = { IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, "Settings") } },
|
||||
actions = { IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } },
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
@@ -123,8 +118,8 @@ private fun BookmarkRow(bookmark: Bookmark, onConnect: () -> Unit, onRemove: ()
|
||||
ListItem(
|
||||
headlineContent = { Text(bookmark.displayName()) },
|
||||
supportingContent = { Text("${bookmark.address}:${bookmark.port}") },
|
||||
leadingContent = { Icon(Icons.Filled.Bookmark, null) },
|
||||
trailingContent = { IconButton(onClick = onRemove) { Icon(Icons.Filled.Delete, "Remove bookmark") } },
|
||||
leadingContent = { PackIcon("BOOKMARK_MANAGER", null) },
|
||||
trailingContent = { IconButton(onClick = onRemove) { PackIcon("DELETE", "Remove bookmark") } },
|
||||
modifier = Modifier.clickable(onClick = onConnect),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -110,7 +109,7 @@ fun MyTeamSpeakScreen(vm: SessionController, onBack: () -> Unit, onImported: ()
|
||||
actions = {
|
||||
if (signedIn) {
|
||||
IconButton(onClick = { work { show(vm.fetchMyTeamSpeak()) } }, enabled = !busy) {
|
||||
Icon(Icons.Filled.Refresh, "Refresh")
|
||||
PackIcon("REFRESH", "Refresh")
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material.icons.filled.Headset
|
||||
import androidx.compose.material.icons.filled.HeadsetOff
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconToggleButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -46,6 +37,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
@@ -126,8 +118,8 @@ fun ServerScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, "Settings") }
|
||||
IconButton(onClick = vm::disconnect) { Icon(Icons.AutoMirrored.Filled.Logout, "Disconnect") }
|
||||
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
|
||||
IconButton(onClick = vm::disconnect) { PackIcon("DISCONNECT", "Disconnect") }
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -222,22 +214,21 @@ private fun VoiceBar(state: SessionState, talking: Boolean, vm: SessionControlle
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!state.micActive) {
|
||||
FilledTonalButton(onClick = onEnableMicrophone) {
|
||||
Icon(Icons.Filled.Mic, null)
|
||||
PackIcon("CAPTURE", null)
|
||||
Text("Use microphone", Modifier.padding(start = 8.dp))
|
||||
}
|
||||
} else {
|
||||
IconToggleButton(state.micMuted, { vm.setMicMuted(it) }, enabled = state.connected) {
|
||||
if (state.micMuted) Icon(Icons.Filled.MicOff, "Unmute microphone", tint = StatusColors.muted)
|
||||
else Icon(Icons.Filled.Mic, "Mute microphone")
|
||||
if (state.micMuted) PackIcon("INPUT_MUTED", "Unmute microphone")
|
||||
else PackIcon("CAPTURE", "Mute microphone")
|
||||
}
|
||||
}
|
||||
IconToggleButton(state.deafened, { vm.setDeafened(it) }, enabled = state.connected) {
|
||||
if (state.deafened) Icon(Icons.Filled.HeadsetOff, "Undeafen", tint = StatusColors.muted)
|
||||
else Icon(Icons.Filled.Headset, "Deafen")
|
||||
if (state.deafened) PackIcon("OUTPUT_MUTED", "Undeafen")
|
||||
else PackIcon("PLAYBACK", "Deafen")
|
||||
}
|
||||
IconToggleButton(state.away, { vm.setAway(it) }, enabled = state.connected) {
|
||||
Icon(Icons.Filled.Bedtime, if (state.away) "Back" else "Away",
|
||||
tint = if (state.away) StatusColors.away else Color.Unspecified)
|
||||
PackIcon("AWAY", if (state.away) "Back" else "Away", Modifier.alpha(if (state.away) 1f else 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.ts3client.android.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -31,6 +33,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -41,12 +44,16 @@ import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ts3client.android.IconTheme
|
||||
import com.ts3client.android.MicLevel
|
||||
import com.ts3client.android.SessionController
|
||||
import com.ts3client.android.audio.AudioRoute
|
||||
import com.ts3client.audio.AudioDevice
|
||||
import com.ts3client.audio.InputLevel
|
||||
import com.ts3client.config.Settings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val DENOISE_LEVELS = listOf("6 dB", "12 dB", "18 dB", "21 dB")
|
||||
@@ -186,6 +193,9 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||
change { outputVolume = it.toDouble() }
|
||||
}
|
||||
|
||||
Section("Appearance")
|
||||
IconPackPicker(vm.icons)
|
||||
|
||||
Section("Account")
|
||||
Column(Modifier.fillMaxWidth().clickable { myTeamSpeak = true }.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text("myTeamSpeak")
|
||||
@@ -284,6 +294,29 @@ private fun <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. */
|
||||
@Composable
|
||||
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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Login
|
||||
import androidx.compose.material.icons.automirrored.outlined.Chat
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -67,10 +59,10 @@ fun ClientSheet(
|
||||
}
|
||||
}
|
||||
if (client.self) {
|
||||
Action(Icons.Filled.Bedtime, if (away) "Back" else "Away") { onAway(!away) }
|
||||
Action("AWAY", if (away) "Back" else "Away") { onAway(!away) }
|
||||
} else {
|
||||
Action(Icons.AutoMirrored.Outlined.Chat, "Send message", onMessage)
|
||||
Action(Icons.Filled.NotificationsActive, "Poke") { poking = true }
|
||||
Action("PLAYER_CHAT", "Send message", onMessage)
|
||||
Action("POKE", "Poke") { poking = true }
|
||||
var volume by remember { mutableFloatStateOf(initialVolume.toFloat()) }
|
||||
Text("Volume: ${if (volume > 0) "+" else ""}${volume.roundToInt()} dB",
|
||||
Modifier.padding(start = 24.dp, top = 8.dp), style = MaterialTheme.typography.bodyMedium)
|
||||
@@ -110,14 +102,14 @@ fun ChannelSheet(
|
||||
Text(channel.name, Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.titleLarge)
|
||||
if (!current) {
|
||||
Action(Icons.AutoMirrored.Filled.Login, "Join") {
|
||||
Action("CHANNEL_SWITCH", "Join") {
|
||||
if (channel.locked) askPassword = true else onJoin(null)
|
||||
}
|
||||
}
|
||||
if (channel.subscribed) {
|
||||
Action(Icons.Filled.VisibilityOff, "Unsubscribe") { onSubscribe(false) }
|
||||
Action("UNSUBSCRIBE_FROM_CHANNEL", "Unsubscribe") { onSubscribe(false) }
|
||||
} else {
|
||||
Action(Icons.Filled.Visibility, "Subscribe") { onSubscribe(true) }
|
||||
Action("SUBSCRIBE_TO_CHANNEL", "Subscribe") { onSubscribe(true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,10 +126,10 @@ fun ChannelSheet(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Action(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) {
|
||||
private fun Action(iconKey: String, label: String, onClick: () -> Unit) {
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
leadingContent = { Icon(icon, null) },
|
||||
leadingContent = { PackIcon(iconKey, null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 8.dp),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user