Share icon packs between desktop and Android

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

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

View File

@@ -0,0 +1,139 @@
package com.ts3client.android
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.net.Uri
import android.provider.OpenableColumns
import android.util.LruCache
import com.caverock.androidsvg.RenderOptions
import com.caverock.androidsvg.SVG
import com.ts3client.config.AppDirs
import com.ts3client.config.Settings
import com.ts3client.gfx.IconPack
import com.ts3client.gfx.IconPacks
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.io.File
/**
* The icon packs the app draws with: the Material pack it ships, and TeamSpeak packs
* imported into its `gfx` folder, which are the very zips a TeamSpeak 3 install keeps in
* its own. Whatever the chosen pack lacks comes from the Material one.
*/
class IconTheme(private val context: Context, private val settings: Settings) {
private val folder = File(AppDirs.profile(), "gfx")
private val material: IconPack = IconPacks.material()
@Volatile
private var lookupOrder = listOf(material)
private val cache = LruCache<String, Bitmap>(256)
private val _packs = MutableStateFlow(emptyList<IconPack>())
val packs: StateFlow<List<IconPack>> = _packs
private val _active = MutableStateFlow(material)
val active: StateFlow<IconPack> = _active
init {
reload()
}
fun select(pack: IconPack) {
settings.iconPack = pack.id()
settings.save()
reload()
}
/**
* Copies a pack's zip into the app and switches to it.
*
* @return the pack, or `null` when the file is no icon pack
*/
fun import(uri: Uri): IconPack? {
folder.mkdirs()
val target = File(folder, displayName(uri).substringAfterLast('/').let {
if (it.endsWith(".zip", ignoreCase = true)) it else "$it.zip"
})
val staged = File(folder, target.name + ".part")
context.contentResolver.openInputStream(uri)?.use { input ->
staged.outputStream().use { input.copyTo(it) }
} ?: return null
target.delete()
staged.renameTo(target)
val pack = IconPack.load(target)
if (pack == null) {
target.delete()
return null
}
select(pack)
return _active.value
}
/**
* Draws the first of [keys] the packs have.
*
* @param rgb the colour for art that paints in the text colour
*/
fun render(keys: List<String>, size: Int, rgb: Int): Bitmap? {
val order = lookupOrder
val cacheKey = "${order.first().id()}/${keys.joinToString(",")}@$size#$rgb"
cache.get(cacheKey)?.let { return it }
for (key in keys) {
for (pack in order) {
val data = pack.icon(key, size) ?: continue
val bitmap = (if (pack.isVector(key, size)) rasterize(IconPack.withCurrentColor(data, rgb), size)
else decode(data, size)) ?: continue
cache.put(cacheKey, bitmap)
return bitmap
}
}
return null
}
private fun reload() {
val packs = IconPacks.findAll(folder, null)
val active = IconPacks.select(packs, settings.iconPack) ?: material
lookupOrder = IconPacks.lookupOrder(active, packs, material)
cache.evictAll()
_packs.value = packs
_active.value = active
}
private fun displayName(uri: Uri): String =
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
if (it.moveToFirst()) it.getString(0) else null
} ?: uri.lastPathSegment ?: "iconpack.zip"
private fun rasterize(svg: ByteArray, size: Int): Bitmap? = try {
val document = SVG.getFromString(String(svg, Charsets.UTF_8))
if (document.documentViewBox == null && document.documentWidth > 0 && document.documentHeight > 0) {
document.setDocumentViewBox(0f, 0f, document.documentWidth, document.documentHeight)
}
// Otherwise the art keeps the size it was authored at inside the viewport.
document.setDocumentWidth("100%")
document.setDocumentHeight("100%")
Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).also {
document.renderToCanvas(Canvas(it), RenderOptions().viewPort(0f, 0f, size.toFloat(), size.toFloat()))
}
} catch (e: Exception) {
null
}
/** Scales a bitmap icon into the square, keeping the aspect ratio of art that is not. */
private fun decode(data: ByteArray, size: Int): Bitmap? {
val source = BitmapFactory.decodeByteArray(data, 0, data.size) ?: return null
if (source.width == size && source.height == size) return source
val factor = minOf(size.toFloat() / source.width, size.toFloat() / source.height)
val w = source.width * factor
val h = source.height * factor
return Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).also {
val left = (size - w) / 2
val top = (size - h) / 2
Canvas(it).drawBitmap(source, null, RectF(left, top, left + w, top + h), Paint(Paint.FILTER_BITMAP_FLAG))
}
}
}

View File

@@ -64,6 +64,7 @@ sealed interface SessionEvent {
class SessionController(private val context: Context) {
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)

View File

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

View File

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

View File

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

View File

@@ -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")
}
}
},

View File

@@ -0,0 +1,42 @@
package com.ts3client.android.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.IconTheme
val LocalIconTheme = staticCompositionLocalOf<IconTheme> { error("No icon theme provided") }
/** An icon of the active pack, named by its TeamSpeak key; art in the text colour takes [LocalContentColor]. */
@Composable
fun PackIcon(key: String, contentDescription: String?, modifier: Modifier = Modifier, size: Dp = 24.dp) =
PackIcon(listOf(key), contentDescription, modifier, size)
/** The first of [keys] the pack has, for icons packs need not ship in every variant. */
@Composable
fun PackIcon(keys: List<String>, contentDescription: String?, modifier: Modifier = Modifier, size: Dp = 24.dp) {
val theme = LocalIconTheme.current
val active by theme.active.collectAsStateWithLifecycle()
val pixels = with(LocalDensity.current) { size.roundToPx() }
val color = LocalContentColor.current
val rgb = color.copy(alpha = 1f).toArgb()
val bitmap = remember(keys, pixels, rgb, active) { theme.render(keys, pixels, rgb)?.asImageBitmap() }
if (bitmap == null) {
Spacer(modifier.size(size))
} else {
// Disabled controls fade their content colour; the pack's own colours fade with it.
Image(bitmap, contentDescription, modifier.size(size), alpha = color.alpha)
}
}

View File

@@ -12,19 +12,10 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.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))
}
}
}

View File

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

View File

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