Open avatars and description pictures full size on Android; fold minor client facts
A client's sheet now shows only their server and channel groups, with the rest (contact, talk power, version, microphone, unique ID, ...) behind "more…". Core marks those facts minor, for frontends short of room; groups without a name or an icon are left out on both clients. Tapping an avatar or a picture in a description opens it on black, to pinch or double-tap zoom. Its menu saves it to Pictures/TeamSpeak, takes it as our avatar (not on our own), copies its address, and opens the link it sat in, which a tap used to do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
package com.ts3client.android
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Puts pictures into the shared Pictures collection, in a TeamSpeak album, where galleries find them. */
|
||||||
|
object SavedPictures {
|
||||||
|
|
||||||
|
/** Saves [data] as [name], its extension following what the data is; false if it could not be. */
|
||||||
|
suspend fun save(context: Context, name: String, data: ByteArray): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(data, 0, data.size, bounds)
|
||||||
|
val mime = bounds.outMimeType ?: return@withContext false
|
||||||
|
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime)
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.Images.Media.DISPLAY_NAME, if (extension == null) name else "$name.$extension")
|
||||||
|
put(MediaStore.Images.Media.MIME_TYPE, mime)
|
||||||
|
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TeamSpeak")
|
||||||
|
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val uri = resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values)
|
||||||
|
?: return@withContext false
|
||||||
|
try {
|
||||||
|
resolver.openOutputStream(uri)!!.use { it.write(data) }
|
||||||
|
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
resolver.delete(uri, null, null)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,8 +16,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One server the app is on: its [ServerSession], and the snapshots the UI shows of it. Session
|
* One server the app is on: its [ServerSession], and the snapshots the UI shows of it. Session
|
||||||
@@ -162,6 +166,19 @@ class ServerController internal constructor(
|
|||||||
conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) }
|
conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Uploads [data] as our avatar; null once it is set, else why not. */
|
||||||
|
suspend fun setAvatar(data: ByteArray): String? {
|
||||||
|
if (!session.isConnected) return "Not connected"
|
||||||
|
val file = withContext(Dispatchers.IO) {
|
||||||
|
File.createTempFile("avatar", null, app.cacheDir).apply { writeBytes(data) }
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
suspendCoroutine { done -> conn.setAvatar(file) { done.resume(it) } }
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- details ----
|
// ---- details ----
|
||||||
|
|
||||||
fun serverDetails(): Details? =
|
fun serverDetails(): Details? =
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class ServerImages internal constructor(private val conn: TeamspeakConnection) {
|
|||||||
return picture
|
return picture
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The avatar's file as the server keeps it; null while it has none or it is still downloading. */
|
||||||
|
fun avatarData(uniqueId: String, flag: String): ByteArray? =
|
||||||
|
if (flag.isEmpty()) null else conn.avatars.get(uniqueId, flag)?.data()
|
||||||
|
|
||||||
/** Decoded no larger than needed: avatars may be far bigger than they are ever shown. */
|
/** Decoded no larger than needed: avatars may be far bigger than they are ever shown. */
|
||||||
private fun decode(data: ByteArray, maxPx: Int): ImageBitmap? {
|
private fun decode(data: ByteArray, maxPx: Int): ImageBitmap? {
|
||||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
/** What the app shows of a connection and the local client on it. */
|
/** What the app shows of a connection and the local client on it. */
|
||||||
@@ -67,6 +68,7 @@ class SessionController(private val context: Context) {
|
|||||||
val settings: Settings = Settings.load()
|
val settings: Settings = Settings.load()
|
||||||
val icons = IconTheme(context, settings)
|
val icons = IconTheme(context, settings)
|
||||||
val pictures = WebPictures(context.cacheDir, settings)
|
val pictures = WebPictures(context.cacheDir, settings)
|
||||||
|
internal val cacheDir: File get() = context.cacheDir
|
||||||
private val bookmarkStore = Bookmarks.load()
|
private val bookmarkStore = Bookmarks.load()
|
||||||
internal val identityStore = IdentityStore.load(settings)
|
internal val identityStore = IdentityStore.load(settings)
|
||||||
val identities = IdentityManager(context, identityStore, settings, bookmarkStore) {
|
val identities = IdentityManager(context, identityStore, settings, bookmarkStore) {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class WebPictures(cacheDir: File, private val settings: Settings) {
|
|||||||
return State.Shown(picture)
|
return State.Shown(picture)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The picture's file as downloaded; null while it is not there. */
|
||||||
|
suspend fun data(url: String): ByteArray? = withContext(Dispatchers.IO) { downloads.get(url)?.readBytes() }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val CACHED = 32
|
const val CACHED = 32
|
||||||
const val MAX_PX = 1024
|
const val MAX_PX = 1024
|
||||||
|
|||||||
@@ -82,11 +82,13 @@ fun App(vm: SessionController) {
|
|||||||
server != null && !connectOpen -> {
|
server != null && !connectOpen -> {
|
||||||
serverStates.SaveableStateProvider(server.id) {
|
serverStates.SaveableStateProvider(server.id) {
|
||||||
CompositionLocalProvider(LocalServerImages provides server.images) {
|
CompositionLocalProvider(LocalServerImages provides server.images) {
|
||||||
|
PictureViewerHost(server) {
|
||||||
ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone,
|
ServerScreen(vm, server, snackbar, { settingsOpen = true }, openContacts, enableMicrophone,
|
||||||
onConnectOther = { connectOpen = true })
|
onConnectOther = { connectOpen = true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, { openContacts(null) }, connectAsking,
|
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, { openContacts(null) }, connectAsking,
|
||||||
onBack = if (current != null) ({ connectOpen = false }) else null)
|
onBack = if (current != null) ({ connectOpen = false }) else null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,13 +157,17 @@ private fun WebPicture(image: Piece.Image, style: TextStyle, onLink: (String) ->
|
|||||||
WebPictures.State.Broken -> PictureLink("BROKEN_IMAGE", image, style, onLink)
|
WebPictures.State.Broken -> PictureLink("BROKEN_IMAGE", image, style, onLink)
|
||||||
is WebPictures.State.Shown -> {
|
is WebPictures.State.Shown -> {
|
||||||
val picture = shown.picture
|
val picture = shown.picture
|
||||||
|
val viewer = LocalPictureViewer.current
|
||||||
Image(
|
Image(
|
||||||
rememberPicturePainter(picture),
|
rememberPicturePainter(picture),
|
||||||
null,
|
null,
|
||||||
Modifier
|
Modifier
|
||||||
.widthIn(max = picture.width.dp)
|
.widthIn(max = picture.width.dp)
|
||||||
.aspectRatio(picture.width.toFloat() / picture.height.coerceAtLeast(1))
|
.aspectRatio(picture.width.toFloat() / picture.height.coerceAtLeast(1))
|
||||||
.then(if (image.href != null) Modifier.clickable { onLink(image.href) } else Modifier),
|
.then(if (viewer != null) Modifier.clickable {
|
||||||
|
val openLink = image.href?.let { href -> { onLink(href) } }
|
||||||
|
viewer(ViewedPicture(pictureName(image.url), image.url, openLink) { pictures.data(image.url) })
|
||||||
|
} else if (image.href != null) Modifier.clickable { onLink(image.href) } else Modifier),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,13 +37,23 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.ts3client.net.Details
|
import com.ts3client.net.Details
|
||||||
import com.ts3client.text.BBCode
|
import com.ts3client.text.BBCode
|
||||||
|
|
||||||
/** The facts of the server, a channel or a client, then its description, for the top of its sheet. */
|
/**
|
||||||
|
* The facts of the server, a channel or a client, then its description, for the top of its sheet.
|
||||||
|
* The minor facts wait behind "more…".
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifier = Modifier) {
|
fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
var all by rememberSaveable(details.title()) { mutableStateOf(false) }
|
||||||
|
val shown = if (all) details.fields() else details.fields().filterNot { it.minor() }
|
||||||
|
val more = !all && shown.size < details.fields().size
|
||||||
Column(modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
if (details.fields().isNotEmpty()) {
|
if (shown.isNotEmpty() || more) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(Modifier.animateContentSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
for (field in details.fields()) Fact(field)
|
for (field in shown) Fact(field)
|
||||||
|
if (more) {
|
||||||
|
Text("more…", Modifier.clickable { all = true }.padding(vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
@@ -65,11 +75,14 @@ private fun Fact(field: Details.Field) {
|
|||||||
} else {
|
} else {
|
||||||
// Each group with its icon, as TeamSpeak's info frame lists them.
|
// Each group with its icon, as TeamSpeak's info frame lists them.
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
// A nameless group shows only its icon, and takes no comma.
|
||||||
|
val lastNamed = field.groups().indexOfLast { it.name.isNotBlank() }
|
||||||
field.groups().forEachIndexed { i, group ->
|
field.groups().forEachIndexed { i, group ->
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
ServerIcon(group.iconId, size = 14.dp)
|
ServerIcon(group.iconId, size = 14.dp)
|
||||||
Text(group.name + if (i < field.groups().size - 1) "," else "",
|
if (group.name.isNotBlank()) {
|
||||||
style = MaterialTheme.typography.bodySmall)
|
Text(group.name.trim() + if (i < lastNamed) "," else "", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package com.ts3client.android.ui
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import com.ts3client.android.Picture
|
||||||
|
import com.ts3client.android.SavedPictures
|
||||||
|
import com.ts3client.android.ServerController
|
||||||
|
import com.ts3client.android.decodePicture
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** A picture to open full size. */
|
||||||
|
class ViewedPicture(
|
||||||
|
/** What to call it when saved, without an extension. */
|
||||||
|
val name: String,
|
||||||
|
/** Where it is on the web; null for an avatar. */
|
||||||
|
val url: String? = null,
|
||||||
|
/** Follows the link it sits in; null when it sits in none. */
|
||||||
|
val openLink: (() -> Unit)? = null,
|
||||||
|
/** Our own avatar, which there is no taking. */
|
||||||
|
val ownAvatar: Boolean = false,
|
||||||
|
/** Its file; null when it is not there. */
|
||||||
|
val read: suspend () -> ByteArray?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Opens a picture full size; null where nothing can. */
|
||||||
|
val LocalPictureViewer = staticCompositionLocalOf<((ViewedPicture) -> Unit)?> { null }
|
||||||
|
|
||||||
|
/** Lets [content] open pictures full size, over everything, with [server] to take one as our avatar on. */
|
||||||
|
@Composable
|
||||||
|
fun PictureViewerHost(server: ServerController, content: @Composable () -> Unit) {
|
||||||
|
var viewed by remember { mutableStateOf<ViewedPicture?>(null) }
|
||||||
|
CompositionLocalProvider(LocalPictureViewer provides { viewed = it }, content = content)
|
||||||
|
val context = LocalContext.current
|
||||||
|
viewed?.let { picture ->
|
||||||
|
PictureViewer(
|
||||||
|
picture,
|
||||||
|
onSteal = if (picture.ownAvatar) null else { data ->
|
||||||
|
val error = server.setAvatar(data)
|
||||||
|
Toast.makeText(context, error ?: "Avatar set.", Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
onDismiss = { viewed = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A name to save the picture at [url] under: its file's, or "picture" when it has none. */
|
||||||
|
fun pictureName(url: String): String =
|
||||||
|
url.substringBefore('?').substringBefore('#').trimEnd('/').substringAfterLast('/')
|
||||||
|
.substringBeforeLast('.').ifBlank { "picture" }
|
||||||
|
|
||||||
|
/** The picture on black, filling the screen; pinch or double-tap to zoom, tap to hide the bar. */
|
||||||
|
@Composable
|
||||||
|
private fun PictureViewer(
|
||||||
|
picture: ViewedPicture,
|
||||||
|
/** Takes the picture as our avatar; null when it cannot be. */
|
||||||
|
onSteal: (suspend (ByteArray) -> Unit)?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by produceState<Shown?>(null, picture) {
|
||||||
|
val data = picture.read()
|
||||||
|
value = Shown(data, data?.let { withContext(Dispatchers.Default) { decodePicture(it, MAX_PX) } })
|
||||||
|
}
|
||||||
|
val data = state?.data
|
||||||
|
var bar by remember { mutableStateOf(true) }
|
||||||
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
|
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
fun panTo(target: Offset) {
|
||||||
|
val maxX = size.width * (scale - 1) / 2
|
||||||
|
val maxY = size.height * (scale - 1) / 2
|
||||||
|
offset = Offset(target.x.coerceIn(-maxX, maxX), target.y.coerceIn(-maxY, maxY))
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(onDismiss, DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(Color.Black).onSizeChanged { size = it }
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = { bar = !bar },
|
||||||
|
onDoubleTap = { scale = if (scale > 1f) 1f else DOUBLE_TAP_SCALE; panTo(Offset.Zero) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTransformGestures { _, pan, zoom, _ ->
|
||||||
|
scale = (scale * zoom).coerceIn(1f, MAX_SCALE)
|
||||||
|
panTo(offset + pan)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val shown = state?.picture
|
||||||
|
when {
|
||||||
|
state == null -> PackIcon("LOADING_IMAGE", "Loading picture")
|
||||||
|
shown == null -> PackIcon("BROKEN_IMAGE", "Broken picture")
|
||||||
|
else -> Image(
|
||||||
|
rememberPicturePainter(shown), null,
|
||||||
|
Modifier.fillMaxSize().graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
translationX = offset.x
|
||||||
|
translationY = offset.y
|
||||||
|
},
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (bar) {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides Color.White) {
|
||||||
|
Bar(picture, data, onSteal, onDismiss, Modifier.align(Alignment.TopCenter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Bar(
|
||||||
|
picture: ViewedPicture,
|
||||||
|
data: ByteArray?,
|
||||||
|
onSteal: (suspend (ByteArray) -> Unit)?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var menu by remember { mutableStateOf(false) }
|
||||||
|
Row(
|
||||||
|
modifier.fillMaxWidth()
|
||||||
|
.background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.6f), Color.Transparent)))
|
||||||
|
.statusBarsPadding(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onDismiss) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
|
||||||
|
Box(Modifier.weight(1f))
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "Picture options") }
|
||||||
|
DropdownMenu(menu, onDismissRequest = { menu = false }) {
|
||||||
|
DropdownMenuItem({ Text("Save") }, enabled = data != null, onClick = {
|
||||||
|
menu = false
|
||||||
|
val bytes = data ?: return@DropdownMenuItem
|
||||||
|
scope.launch {
|
||||||
|
val saved = SavedPictures.save(context, picture.name, bytes)
|
||||||
|
Toast.makeText(context, if (saved) "Saved." else "Could not save.", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (onSteal != null) {
|
||||||
|
DropdownMenuItem({ Text("Steal for avatar") }, enabled = data != null, onClick = {
|
||||||
|
menu = false
|
||||||
|
val bytes = data ?: return@DropdownMenuItem
|
||||||
|
scope.launch { onSteal(bytes) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
picture.url?.let { url ->
|
||||||
|
DropdownMenuItem({ Text("Copy URL") }, onClick = {
|
||||||
|
menu = false
|
||||||
|
context.getSystemService(ClipboardManager::class.java)
|
||||||
|
.setPrimaryClip(ClipData.newPlainText("URL", url))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
picture.openLink?.let { open ->
|
||||||
|
DropdownMenuItem({ Text("Open link") }, onClick = {
|
||||||
|
menu = false
|
||||||
|
onDismiss()
|
||||||
|
open()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picture's file, and the picture when the file is one; both null when it is not there. */
|
||||||
|
private class Shown(val data: ByteArray?, val picture: Picture?)
|
||||||
|
|
||||||
|
private const val MAX_PX = 4096
|
||||||
|
private const val MAX_SCALE = 8f
|
||||||
|
private const val DOUBLE_TAP_SCALE = 2.5f
|
||||||
@@ -74,7 +74,13 @@ fun ClientSheet(
|
|||||||
if (client.name != client.nickname) Text(client.nickname, style = MaterialTheme.typography.bodyMedium)
|
if (client.name != client.nickname) Text(client.nickname, style = MaterialTheme.typography.bodyMedium)
|
||||||
if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium)
|
if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
Avatar(client.uniqueId, client.avatarFlag)
|
val viewer = LocalPictureViewer.current
|
||||||
|
val images = LocalServerImages.current
|
||||||
|
Avatar(client.uniqueId, client.avatarFlag, Modifier.clickable(enabled = viewer != null && images != null) {
|
||||||
|
viewer?.invoke(ViewedPicture("avatar_${client.nickname}", ownAvatar = client.self) {
|
||||||
|
images?.avatarData(client.uniqueId, client.avatarFlag)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
|
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
|
||||||
if (client.self) {
|
if (client.self) {
|
||||||
|
|||||||
@@ -23,10 +23,19 @@ public record Details(String title, List<Field> fields, String description, bool
|
|||||||
*
|
*
|
||||||
* @param groups the groups {@code value} names, in order, for frontends that show their icons;
|
* @param groups the groups {@code value} names, in order, for frontends that show their icons;
|
||||||
* empty when it names none
|
* empty when it names none
|
||||||
|
* @param minor for frontends short of room to show only on asking
|
||||||
*/
|
*/
|
||||||
public record Field(String label, String value, List<Group> groups) {
|
public record Field(String label, String value, List<Group> groups, boolean minor) {
|
||||||
Field(String label, String value) {
|
Field(String label, String value) {
|
||||||
this(label, value, List.of());
|
this(label, value, List.of(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Field(String label, String value, List<Group> groups) {
|
||||||
|
this(label, value, groups, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Field minor(String label, String value) {
|
||||||
|
return new Field(label, value, List.of(), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +75,7 @@ public record Details(String title, List<Field> fields, String description, bool
|
|||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
String type = contact.type.label();
|
String type = contact.type.label();
|
||||||
if (!contact.phoneticNickname.isEmpty()) type += " (" + contact.phoneticNickname + ")";
|
if (!contact.phoneticNickname.isEmpty()) type += " (" + contact.phoneticNickname + ")";
|
||||||
fields.add(new Field("Contact", type));
|
fields.add(Field.minor("Contact", type));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
|
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
|
||||||
@@ -74,27 +83,33 @@ public record Details(String title, List<Field> fields, String description, bool
|
|||||||
List<String> names = model.serverGroupNames(cl.serverGroupIds);
|
List<String> names = model.serverGroupNames(cl.serverGroupIds);
|
||||||
fields.add(new Field("Server groups", names.isEmpty() ? "—" : String.join(", ", names)));
|
fields.add(new Field("Server groups", names.isEmpty() ? "—" : String.join(", ", names)));
|
||||||
} else {
|
} else {
|
||||||
|
// A nameless group is there for its icon only; without one, there is nothing to show of it.
|
||||||
|
List<Group> shown = new ArrayList<>();
|
||||||
List<String> names = new ArrayList<>();
|
List<String> names = new ArrayList<>();
|
||||||
for (Group g : serverGroups) names.add(g.name);
|
for (Group g : serverGroups) {
|
||||||
fields.add(new Field("Server groups", String.join(", ", names), serverGroups));
|
if (g.name.isBlank() && g.iconId == 0) continue;
|
||||||
|
shown.add(g);
|
||||||
|
if (!g.name.isBlank()) names.add(g.name.trim());
|
||||||
|
}
|
||||||
|
fields.add(new Field("Server groups", names.isEmpty() ? "—" : String.join(", ", names), shown));
|
||||||
}
|
}
|
||||||
Group channelGroup = model.channelGroup(cl.channelGroupId);
|
Group channelGroup = model.channelGroup(cl.channelGroupId);
|
||||||
fields.add(channelGroup != null
|
fields.add(channelGroup != null
|
||||||
? new Field("Channel group", channelGroup.name, List.of(channelGroup))
|
? new Field("Channel group", channelGroup.name, List.of(channelGroup))
|
||||||
: new Field("Channel group", "#" + cl.channelGroupId));
|
: new Field("Channel group", "#" + cl.channelGroupId));
|
||||||
|
|
||||||
if (cl.talkPower != 0) fields.add(new Field("Talk power", Integer.toString(cl.talkPower)));
|
if (cl.talkPower != 0) fields.add(Field.minor("Talk power", Integer.toString(cl.talkPower)));
|
||||||
if (!cl.version.isEmpty()) {
|
if (!cl.version.isEmpty()) {
|
||||||
fields.add(new Field("Version", cl.version + (cl.platform.isEmpty() ? "" : " on " + cl.platform)));
|
fields.add(Field.minor("Version", cl.version + (cl.platform.isEmpty() ? "" : " on " + cl.platform)));
|
||||||
} else if (!cl.platform.isEmpty()) {
|
} else if (!cl.platform.isEmpty()) {
|
||||||
fields.add(new Field("Platform", cl.platform));
|
fields.add(Field.minor("Platform", cl.platform));
|
||||||
}
|
}
|
||||||
if (cl.away) fields.add(new Field("Status", "Away"));
|
if (cl.away) fields.add(Field.minor("Status", "Away"));
|
||||||
if (!cl.inputHardware) fields.add(new Field("Microphone", "disabled"));
|
if (!cl.inputHardware) fields.add(Field.minor("Microphone", "disabled"));
|
||||||
else if (cl.inputMuted) fields.add(new Field("Microphone", "muted"));
|
else if (cl.inputMuted) fields.add(Field.minor("Microphone", "muted"));
|
||||||
if (!cl.outputHardware) fields.add(new Field("Speakers", "disabled"));
|
if (!cl.outputHardware) fields.add(Field.minor("Speakers", "disabled"));
|
||||||
else if (cl.outputMuted) fields.add(new Field("Speakers", "muted"));
|
else if (cl.outputMuted) fields.add(Field.minor("Speakers", "muted"));
|
||||||
if (!cl.uniqueId.isEmpty()) fields.add(new Field("Unique ID", cl.uniqueId));
|
if (!cl.uniqueId.isEmpty()) fields.add(Field.minor("Unique ID", cl.uniqueId));
|
||||||
|
|
||||||
String name = contact == null ? cl.nickname : contact.displayName(cl.nickname);
|
String name = contact == null ? cl.nickname : contact.displayName(cl.nickname);
|
||||||
return new Details(name, fields, cl.description == null ? "" : cl.description, false);
|
return new Details(name, fields, cl.description == null ? "" : cl.description, false);
|
||||||
|
|||||||
Reference in New Issue
Block a user