Show pictures in descriptions, or links to them when turned off
[img] in a channel, client or server description is now a picture on both clients, as TeamSpeak shows it; chat keeps it a link. Only web addresses become pictures. While one loads it is TeamSpeak's loading icon; one that cannot be had is its address behind the broken-picture icon. "Show pictures in descriptions" (desktop: Chat settings; Android: Appearance) turns them into links behind a picture icon instead, fetching nothing. A picture that was itself a link keeps that link beside it. Core's PictureDownloads fetches them for both clients: kept on disk for a month after last shown, at most 8 MB, redirects followed across http and https, and on Android plain http asked for over https. The desktop draws them narrowed to the info panel, Android at a pixel to a dp, animated GIFs playing on both; long addresses are shortened to host and file name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
42
android/app/src/main/java/com/ts3client/android/Picture.kt
Normal file
42
android/app/src/main/java/com/ts3client/android/Picture.kt
Normal file
@@ -0,0 +1,42 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/** An avatar or a picture from a description: still, or moving like an animated GIF. */
|
||||
sealed interface Picture {
|
||||
val width: Int
|
||||
val height: Int
|
||||
|
||||
data class Still(val bitmap: ImageBitmap) : Picture {
|
||||
override val width get() = bitmap.width
|
||||
override val height get() = bitmap.height
|
||||
}
|
||||
|
||||
/** Shared by whatever shows it: one animation, drawn wherever it is on screen. */
|
||||
data class Moving(val drawable: AnimatedImageDrawable) : Picture {
|
||||
override val width get() = drawable.intrinsicWidth
|
||||
override val height get() = drawable.intrinsicHeight
|
||||
}
|
||||
}
|
||||
|
||||
/** Decodes any image Android knows, no larger than [maxPx] on its longer side; null if it is none. */
|
||||
internal fun decodePicture(data: ByteArray, maxPx: Int): Picture? = try {
|
||||
val drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(ByteBuffer.wrap(data))) { decoder, info, _ ->
|
||||
val largest = maxOf(info.size.width, info.size.height)
|
||||
if (largest > maxPx) {
|
||||
decoder.setTargetSize(info.size.width * maxPx / largest, info.size.height * maxPx / largest)
|
||||
}
|
||||
}
|
||||
when (drawable) {
|
||||
is AnimatedImageDrawable -> Picture.Moving(drawable)
|
||||
is BitmapDrawable -> Picture.Still(drawable.bitmap.asImageBitmap())
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import com.ts3client.net.TeamspeakConnection
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -20,7 +16,7 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
class ServerImages internal constructor(private val conn: TeamspeakConnection) {
|
||||
|
||||
private val icons = ConcurrentHashMap<Long, ImageBitmap>()
|
||||
private val avatars = ConcurrentHashMap<String, AvatarImage>()
|
||||
private val avatars = ConcurrentHashMap<String, Picture>()
|
||||
|
||||
private val _version = MutableStateFlow(0)
|
||||
val version: StateFlow<Int> = _version
|
||||
@@ -35,30 +31,12 @@ class ServerImages internal constructor(private val conn: TeamspeakConnection) {
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun avatar(uniqueId: String, flag: String): AvatarImage? {
|
||||
fun avatar(uniqueId: String, flag: String): Picture? {
|
||||
if (flag.isEmpty()) return null
|
||||
avatars[flag]?.let { return it }
|
||||
val avatar = conn.avatars.get(uniqueId, flag) ?: return null
|
||||
val image = if (avatar.isAnimated) decodeAnimated(avatar.data()) else decode(avatar.data(), AVATAR_PX)?.let(AvatarImage::Still)
|
||||
if (image != null) avatars[flag] = image
|
||||
return image
|
||||
}
|
||||
|
||||
/** A GIF as it moves; one of a single frame stays still. */
|
||||
private fun decodeAnimated(data: ByteArray): AvatarImage? = try {
|
||||
val drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(ByteBuffer.wrap(data))) { decoder, info, _ ->
|
||||
val largest = maxOf(info.size.width, info.size.height)
|
||||
if (largest > AVATAR_PX) {
|
||||
decoder.setTargetSize(info.size.width * AVATAR_PX / largest, info.size.height * AVATAR_PX / largest)
|
||||
}
|
||||
}
|
||||
when (drawable) {
|
||||
is AnimatedImageDrawable -> AvatarImage.Moving(drawable)
|
||||
is BitmapDrawable -> AvatarImage.Still(drawable.bitmap.asImageBitmap())
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
decode(data, AVATAR_PX)?.let(AvatarImage::Still)
|
||||
val picture = conn.avatars.get(uniqueId, flag)?.let { decodePicture(it.data(), AVATAR_PX) } ?: return null
|
||||
avatars[flag] = picture
|
||||
return picture
|
||||
}
|
||||
|
||||
/** Decoded no larger than needed: avatars may be far bigger than they are ever shown. */
|
||||
@@ -77,10 +55,3 @@ class ServerImages internal constructor(private val conn: TeamspeakConnection) {
|
||||
const val AVATAR_PX = 512
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface AvatarImage {
|
||||
data class Still(val bitmap: ImageBitmap) : AvatarImage
|
||||
|
||||
/** Shared by whatever shows it: one animation, drawn wherever it is on screen. */
|
||||
data class Moving(val drawable: AnimatedImageDrawable) : AvatarImage
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ class SessionController(private val context: Context) {
|
||||
|
||||
val settings: Settings = Settings.load()
|
||||
val icons = IconTheme(context, settings)
|
||||
val pictures = WebPictures(context.cacheDir, settings)
|
||||
private val bookmarkStore = Bookmarks.load()
|
||||
internal val identityStore = IdentityStore.load(settings)
|
||||
val identities = IdentityManager(context, identityStore, settings, bookmarkStore) {
|
||||
@@ -318,6 +319,11 @@ class SessionController(private val context: Context) {
|
||||
|
||||
val soundPack: SoundPack? get() = sounds.pack()
|
||||
|
||||
fun setDescriptionPictures(shown: Boolean) {
|
||||
settings.descriptionPictures = shown
|
||||
settings.save()
|
||||
}
|
||||
|
||||
fun setSoundPack(pack: SoundPack) {
|
||||
sounds.setPack(pack)
|
||||
settings.save()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import android.util.LruCache
|
||||
import com.ts3client.config.Settings
|
||||
import com.ts3client.net.PictureDownloads
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** The pictures descriptions show, fetched by core's downloads and decoded while there is room. */
|
||||
class WebPictures(cacheDir: File, private val settings: Settings) {
|
||||
|
||||
sealed interface State {
|
||||
data object Loading : State
|
||||
data object Broken : State
|
||||
data class Shown(val picture: Picture) : State
|
||||
}
|
||||
|
||||
private val downloads = PictureDownloads(File(cacheDir, "pictures"), false)
|
||||
private val decoded = LruCache<String, Picture>(CACHED)
|
||||
private val undecodable = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
private val _version = MutableStateFlow(0)
|
||||
/** Moves on whenever a download has come or failed. */
|
||||
val version: StateFlow<Int> = _version
|
||||
|
||||
init {
|
||||
downloads.addListener { _version.update { it + 1 } }
|
||||
}
|
||||
|
||||
/** Whether pictures are shown at all, rather than links to them. */
|
||||
val enabled: Boolean get() = settings.descriptionPictures
|
||||
|
||||
suspend fun state(url: String): State {
|
||||
decoded.get(url)?.let { return State.Shown(it) }
|
||||
if (url in undecodable) return State.Broken
|
||||
val file = downloads.get(url) ?: return if (downloads.failed(url)) State.Broken else State.Loading
|
||||
val picture = withContext(Dispatchers.IO) { decodePicture(file.readBytes(), MAX_PX) }
|
||||
if (picture == null) {
|
||||
undecodable += url
|
||||
return State.Broken
|
||||
}
|
||||
decoded.put(url, picture)
|
||||
return State.Shown(picture)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CACHED = 32
|
||||
const val MAX_PX = 1024
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ fun App(vm: SessionController) {
|
||||
contactDirect = uniqueId != null
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalIconTheme provides vm.icons) {
|
||||
CompositionLocalProvider(LocalIconTheme provides vm.icons, LocalWebPictures provides vm.pictures) {
|
||||
val server = current
|
||||
when {
|
||||
settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false })
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
package com.ts3client.android.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -20,26 +29,161 @@ import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ts3client.android.WebPictures
|
||||
import com.ts3client.text.BBCode
|
||||
import com.ts3client.text.ChatHtml
|
||||
|
||||
/**
|
||||
* HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers:
|
||||
* Android's HTML parser knows none, so the text is split at them.
|
||||
* HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers
|
||||
* and its pictures fetched from the web: Android's HTML parser shows neither, so the text is
|
||||
* split at them.
|
||||
*/
|
||||
@Composable
|
||||
fun RichText(html: String, onLink: (String) -> Unit, modifier: Modifier = Modifier, style: TextStyle = LocalTextStyle.current) {
|
||||
val link by rememberUpdatedState(onLink)
|
||||
val color = linkColor()
|
||||
val parts = remember(html, color) { html.split(RULE).map { chatText(it, color) { href -> link(href) } } }
|
||||
val pieces = remember(html, color) { split(html) { chatText(it, color) { href -> link(href) } } }
|
||||
Column(modifier) {
|
||||
parts.forEachIndexed { i, text ->
|
||||
if (i > 0) HorizontalDivider(Modifier.padding(vertical = 6.dp))
|
||||
// As wide as there is room, for aligned paragraphs to have somewhere to go.
|
||||
if (text.isNotEmpty()) Text(text, Modifier.fillMaxWidth(), style = style)
|
||||
for (piece in pieces) {
|
||||
when (piece) {
|
||||
Piece.Rule -> HorizontalDivider(Modifier.padding(vertical = 6.dp))
|
||||
is Piece.Image -> WebPicture(piece, style) { link(it) }
|
||||
// As wide as there is room, for aligned paragraphs to have somewhere to go.
|
||||
is Piece.Text -> if (piece.text.isNotEmpty()) Text(piece.text, Modifier.fillMaxWidth(), style = style)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The web pictures descriptions show. */
|
||||
val LocalWebPictures = staticCompositionLocalOf<WebPictures?> { null }
|
||||
|
||||
private sealed interface Piece {
|
||||
data class Text(val text: AnnotatedString) : Piece
|
||||
data object Rule : Piece
|
||||
/** @param href where the picture links to, if it sits in a link */
|
||||
data class Image(val url: String, val href: String?, val align: Alignment.Horizontal) : Piece
|
||||
}
|
||||
|
||||
/**
|
||||
* [html] cut at its rules and pictures. Each piece of text is closed where it is cut and the
|
||||
* next reopens what it was in, so an aligned block or a link carries on past a picture.
|
||||
*/
|
||||
private fun split(html: String, text: (String) -> AnnotatedString): List<Piece> {
|
||||
val pieces = ArrayList<Piece>()
|
||||
val divs = ArrayList<String>()
|
||||
var anchor: String? = null
|
||||
var href: String? = null
|
||||
val current = StringBuilder()
|
||||
fun cut() {
|
||||
if (anchor != null) current.append("</a>")
|
||||
repeat(divs.size) { current.append("</div>") }
|
||||
pieces += Piece.Text(text(current.toString()))
|
||||
current.setLength(0)
|
||||
divs.forEach { current.append(it) }
|
||||
anchor?.let { current.append(it) }
|
||||
}
|
||||
var last = 0
|
||||
for (m in CUT_TAG.findAll(html)) {
|
||||
current.append(html, last, m.range.first)
|
||||
last = m.range.last + 1
|
||||
val tag = m.value
|
||||
when {
|
||||
tag.startsWith("<hr", ignoreCase = true) -> {
|
||||
cut()
|
||||
pieces += Piece.Rule
|
||||
}
|
||||
tag.startsWith("<img", ignoreCase = true) -> {
|
||||
cut()
|
||||
pieces += Piece.Image(unescape(m.groupValues[1]), href?.let(::unescape), alignment(divs))
|
||||
}
|
||||
tag.startsWith("<div", ignoreCase = true) -> {
|
||||
divs += tag
|
||||
current.append(tag)
|
||||
}
|
||||
tag.equals("</div>", ignoreCase = true) -> {
|
||||
divs.removeLastOrNull()
|
||||
current.append(tag)
|
||||
}
|
||||
tag.startsWith("<a", ignoreCase = true) -> {
|
||||
anchor = tag
|
||||
href = m.groupValues[2]
|
||||
current.append(tag)
|
||||
}
|
||||
else -> {
|
||||
anchor = null
|
||||
href = null
|
||||
current.append(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
current.append(html, last, html.length)
|
||||
pieces += Piece.Text(text(current.toString()))
|
||||
return pieces
|
||||
}
|
||||
|
||||
/** Where the innermost aligned block puts what is in it. */
|
||||
private fun alignment(divs: List<String>): Alignment.Horizontal {
|
||||
val align = divs.asReversed().firstNotNullOfOrNull { ALIGN.find(it)?.groupValues?.get(1) }
|
||||
return when (align) {
|
||||
"center" -> Alignment.CenterHorizontally
|
||||
"right" -> Alignment.End
|
||||
else -> Alignment.Start
|
||||
}
|
||||
}
|
||||
|
||||
private fun unescape(s: String) =
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&")
|
||||
|
||||
/**
|
||||
* A picture at its own size, a pixel to a dp as TeamSpeak shows it, narrowed to fit. With
|
||||
* pictures turned off, or when one cannot be had, its address as a link behind a picture icon.
|
||||
*/
|
||||
@Composable
|
||||
private fun WebPicture(image: Piece.Image, style: TextStyle, onLink: (String) -> Unit) {
|
||||
val pictures = LocalWebPictures.current
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalAlignment = image.align) {
|
||||
if (pictures == null || !pictures.enabled) {
|
||||
PictureLink("ICONVIEWER", image, style, onLink)
|
||||
return@Column
|
||||
}
|
||||
val version by pictures.version.collectAsStateWithLifecycle()
|
||||
val state by produceState<WebPictures.State>(WebPictures.State.Loading, image.url, version) {
|
||||
value = pictures.state(image.url)
|
||||
}
|
||||
when (val shown = state) {
|
||||
WebPictures.State.Loading -> PackIcon("LOADING_IMAGE", "Loading picture", size = 16.dp)
|
||||
WebPictures.State.Broken -> PictureLink("BROKEN_IMAGE", image, style, onLink)
|
||||
is WebPictures.State.Shown -> {
|
||||
val picture = shown.picture
|
||||
Image(
|
||||
rememberPicturePainter(picture),
|
||||
null,
|
||||
Modifier
|
||||
.widthIn(max = picture.width.dp)
|
||||
.aspectRatio(picture.width.toFloat() / picture.height.coerceAtLeast(1))
|
||||
.then(if (image.href != null) Modifier.clickable { onLink(image.href) } else Modifier),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The picture's address as a link behind [iconKey], then the link it sat in, if any, so neither is lost. */
|
||||
@Composable
|
||||
private fun PictureLink(iconKey: String, image: Piece.Image, style: TextStyle, onLink: (String) -> Unit) {
|
||||
val color = linkColor()
|
||||
val text = remember(image, color) {
|
||||
val links = listOfNotNull(image.url, image.href?.takeIf { it != image.url })
|
||||
chatText(links.joinToString(" ") { "<a href=\"${BBCode.escape(it)}\">${BBCode.escape(BBCode.shortAddress(it))}</a>" }, color, onLink)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
PackIcon(iconKey, "Picture", size = 16.dp)
|
||||
Text(text, style = style)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML without rules as styled text. Android's parser knows no stylesheets, tables,
|
||||
* numbered lists, font sizes or `align`, so the classes [ChatHtml] marks lines with become
|
||||
@@ -113,7 +257,11 @@ private val LI = Regex("</?li>")
|
||||
private val SPAN = Regex("<span style=\"font-size:(\\d+)px\">|<span[^>]*>|</span>")
|
||||
private val ALIGNS = mapOf("left" to "start", "justify" to "start", "center" to "center", "right" to "end")
|
||||
|
||||
private val RULE = Regex("<hr\\s*/?>", RegexOption.IGNORE_CASE)
|
||||
/** Where the text is cut, and what it has to carry across: aligned blocks and links. */
|
||||
private val CUT_TAG = Regex(
|
||||
"<hr\\s*/?>|<img src=\"([^\"]*)\">|<div[^>]*>|</div>|<a [^>]*href=\"([^\"]*)\"[^>]*>|</a>",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
private val CLASS = Regex("class=\"([a-z]+)\"")
|
||||
private val ALIGN = Regex("align=\"(left|center|right|justify)\"")
|
||||
private val CELL_END = Regex("</t[dh]>")
|
||||
|
||||
@@ -80,7 +80,7 @@ private fun Fact(field: Details.Field) {
|
||||
/** The description in a box that opens up, when there is more of it than fits folded. */
|
||||
@Composable
|
||||
private fun Description(bbcode: String, onLink: (String) -> Unit) {
|
||||
val html = remember(bbcode) { BBCode.toHtml(bbcode) }
|
||||
val html = remember(bbcode) { BBCode.toHtml(bbcode, true) }
|
||||
var expanded by rememberSaveable(bbcode) { mutableStateOf(false) }
|
||||
var overflows by remember(bbcode) { mutableStateOf(false) }
|
||||
val expandable = overflows || expanded
|
||||
|
||||
@@ -29,7 +29,7 @@ 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.AvatarImage
|
||||
import com.ts3client.android.Picture
|
||||
import com.ts3client.android.ServerImages
|
||||
import com.ts3client.net.IconRepository
|
||||
import kotlin.math.roundToInt
|
||||
@@ -66,15 +66,17 @@ fun ServerIcon(id: Long, modifier: Modifier = Modifier, size: Dp = 16.dp) {
|
||||
fun Avatar(uniqueId: String, flag: String, modifier: Modifier = Modifier, size: Dp = 64.dp) {
|
||||
val images = LocalServerImages.current ?: return
|
||||
val version by images.version.collectAsStateWithLifecycle()
|
||||
val image = remember(flag, version) { images.avatar(uniqueId, flag) } ?: return
|
||||
val painter = when (image) {
|
||||
is AvatarImage.Still -> remember(image) { BitmapPainter(image.bitmap) }
|
||||
is AvatarImage.Moving -> rememberMovingPainter(image.drawable)
|
||||
}
|
||||
Image(painter, "Avatar", modifier.size(size).clip(RoundedCornerShape(8.dp)))
|
||||
val picture = remember(flag, version) { images.avatar(uniqueId, flag) } ?: return
|
||||
Image(rememberPicturePainter(picture), "Avatar", modifier.size(size).clip(RoundedCornerShape(8.dp)))
|
||||
}
|
||||
|
||||
/** Draws [picture], playing it while on screen if it moves. */
|
||||
@Composable
|
||||
fun rememberPicturePainter(picture: Picture): Painter = when (picture) {
|
||||
is Picture.Still -> remember(picture) { BitmapPainter(picture.bitmap) }
|
||||
is Picture.Moving -> rememberMovingPainter(picture.drawable)
|
||||
}
|
||||
|
||||
/** Plays [drawable] while on screen, redrawing at each of its frames. */
|
||||
@Composable
|
||||
private fun rememberMovingPainter(drawable: AnimatedImageDrawable): Painter {
|
||||
val painter = remember(drawable) { MovingPainter(drawable) }
|
||||
|
||||
@@ -50,6 +50,11 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
|
||||
"MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK }
|
||||
Section("Appearance")
|
||||
IconPackPicker(vm.icons)
|
||||
var pictures by remember { mutableStateOf(vm.settings.descriptionPictures) }
|
||||
Toggle("Show pictures in descriptions", pictures) {
|
||||
pictures = it
|
||||
vm.setDescriptionPictures(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user