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,24 +29,159 @@ 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))
|
||||
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.
|
||||
if (text.isNotEmpty()) Text(text, Modifier.fillMaxWidth(), style = style)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,8 @@ public final class Settings {
|
||||
public boolean chatLogs = true;
|
||||
/** Where chat logs go; empty for the TeamSpeak 3 client's own folder, so both share them. */
|
||||
public String chatLogDir = "";
|
||||
/** Whether descriptions show their pictures, fetched from the web, or only links to them. */
|
||||
public boolean descriptionPictures = true;
|
||||
|
||||
public static Settings load() {
|
||||
Settings s = new Settings();
|
||||
@@ -279,6 +281,7 @@ public final class Settings {
|
||||
notifications.load(props);
|
||||
contactDefaults.load(props);
|
||||
colorContacts = parseB(props.getProperty("colorContacts"), colorContacts);
|
||||
descriptionPictures = parseB(props.getProperty("descriptionPictures"), descriptionPictures);
|
||||
whisperPolicy = parseEnum(WhisperPolicy.class, props.getProperty("whisperPolicy"), whisperPolicy);
|
||||
chatLogs = parseB(props.getProperty("chatLogs"), chatLogs);
|
||||
chatLogDir = props.getProperty("chatLogDir", chatLogDir);
|
||||
@@ -333,6 +336,7 @@ public final class Settings {
|
||||
notifications.store(props);
|
||||
contactDefaults.store(props);
|
||||
props.setProperty("colorContacts", Boolean.toString(colorContacts));
|
||||
props.setProperty("descriptionPictures", Boolean.toString(descriptionPictures));
|
||||
props.setProperty("whisperPolicy", whisperPolicy.name());
|
||||
props.setProperty("chatLogs", Boolean.toString(chatLogs));
|
||||
props.setProperty("chatLogDir", chatLogDir);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* The pictures descriptions show, fetched from the web on first sight and kept on disk
|
||||
* for a month after they were last shown.
|
||||
*
|
||||
* <p>Lookups never block: {@link #get} returns {@code null} while a picture is on its
|
||||
* way, and the listeners hear when one has come or could not be had.
|
||||
*/
|
||||
public final class PictureDownloads {
|
||||
|
||||
private static final long MAX_BYTES = 8L << 20;
|
||||
private static final int TIMEOUT_MS = 10_000;
|
||||
private static final long KEEP_MS = 30L * 24 * 60 * 60 * 1000;
|
||||
private static final String USER_AGENT = "ts3client";
|
||||
private static final int MAX_REDIRECTS = 5;
|
||||
|
||||
private final File dir;
|
||||
private final boolean plainHttp;
|
||||
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
|
||||
private final Set<String> pending = ConcurrentHashMap.newKeySet();
|
||||
private final Set<String> failed = ConcurrentHashMap.newKeySet();
|
||||
private final ExecutorService downloads = Executors.newFixedThreadPool(2, r -> {
|
||||
Thread t = new Thread(r, "picture-download");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
/**
|
||||
* @param plainHttp whether plain http may be used; where it may not (Android), an
|
||||
* http address is fetched over https, which most hosts also serve
|
||||
*/
|
||||
public PictureDownloads(File dir, boolean plainHttp) {
|
||||
this.dir = dir;
|
||||
this.plainHttp = plainHttp;
|
||||
downloads.execute(this::prune);
|
||||
}
|
||||
|
||||
/** @param listener run off the UI thread whenever a picture has come or failed */
|
||||
public void addListener(Runnable listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
public void removeListener(Runnable listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the picture's file, or {@code null} while it downloads or when it cannot be
|
||||
* had (see {@link #failed}). A picture not on disk yet starts downloading.
|
||||
*/
|
||||
public File get(String url) {
|
||||
File file = new File(dir, name(url));
|
||||
if (file.isFile()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
file.setLastModified(System.currentTimeMillis());
|
||||
return file;
|
||||
}
|
||||
if (!failed.contains(url) && pending.add(url)) downloads.execute(() -> download(url, file));
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether the picture could not be had this session; it is not asked for again until then. */
|
||||
public boolean failed(String url) {
|
||||
return failed.contains(url);
|
||||
}
|
||||
|
||||
private void download(String url, File file) {
|
||||
try {
|
||||
byte[] data = fetch(allowed(url));
|
||||
if (data == null) {
|
||||
failed.add(url);
|
||||
} else {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
dir.mkdirs();
|
||||
File part = new File(dir, file.getName() + ".part");
|
||||
Files.write(part.toPath(), data);
|
||||
Files.move(part.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
failed.add(url);
|
||||
} finally {
|
||||
pending.remove(url);
|
||||
}
|
||||
for (Runnable listener : listeners) listener.run();
|
||||
}
|
||||
|
||||
/** The address as it may be fetched here: over https where plain http is not allowed. */
|
||||
private String allowed(String url) {
|
||||
boolean http = url.regionMatches(true, 0, "http://", 0, 7);
|
||||
return plainHttp || !http ? url : "https://" + url.substring(7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows redirects itself: Java does not follow one that changes protocol, which is how
|
||||
* many hosts send plain http on to https.
|
||||
*/
|
||||
private byte[] fetch(String url) throws IOException {
|
||||
for (int hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
||||
try {
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
connection.setConnectTimeout(TIMEOUT_MS);
|
||||
connection.setReadTimeout(TIMEOUT_MS);
|
||||
// Some hosts turn away Java's own agent (Wikimedia does).
|
||||
connection.setRequestProperty("User-Agent", USER_AGENT);
|
||||
int code = connection.getResponseCode();
|
||||
String location = connection.getHeaderField("Location");
|
||||
if (code / 100 == 3 && location != null) {
|
||||
String next = new URL(new URL(url), location).toString();
|
||||
if (!next.regionMatches(true, 0, "http", 0, 4)) return null;
|
||||
url = allowed(next);
|
||||
continue;
|
||||
}
|
||||
return code == HttpURLConnection.HTTP_OK ? read(connection) : null;
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] read(HttpURLConnection connection) throws IOException {
|
||||
if (connection.getContentLengthLong() > MAX_BYTES) return null;
|
||||
try (InputStream in = connection.getInputStream()) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
for (int n; (n = in.read(buffer)) >= 0; ) {
|
||||
out.write(buffer, 0, n);
|
||||
if (out.size() > MAX_BYTES) return null;
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** Forgets the pictures not shown for a month. */
|
||||
private void prune() {
|
||||
File[] files = dir.listFiles();
|
||||
if (files == null) return;
|
||||
long cutoff = System.currentTimeMillis() - KEEP_MS;
|
||||
for (File f : files) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
if (f.lastModified() < cutoff) f.delete();
|
||||
}
|
||||
}
|
||||
|
||||
private static String name(String url) {
|
||||
try {
|
||||
byte[] hash = MessageDigest.getInstance("SHA-1").digest(url.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hash) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ public final class BBCode {
|
||||
Pattern.compile("\\[url]([^\\[\\]]+)\\[/url]", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern BARE_IMG_TAG =
|
||||
Pattern.compile("\\[img]([^\\[\\]]+)\\[/img]", Pattern.CASE_INSENSITIVE);
|
||||
/** A picture's address: only the web, so a description cannot point the client anywhere else. */
|
||||
private static final Pattern IMAGE_URL = Pattern.compile("(?i)https?://[^\\s\"'<>]+");
|
||||
private static final int MAX_ADDRESS = 48;
|
||||
private static final Pattern IMG_CLOSE = Pattern.compile("(?i)\\[/img]");
|
||||
private static final Pattern PLAIN_URL =
|
||||
Pattern.compile("(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+");
|
||||
private static final Pattern COLOR = Pattern.compile("#[0-9a-fA-F]{3,6}|[a-zA-Z]{3,20}");
|
||||
@@ -44,11 +48,19 @@ public final class BBCode {
|
||||
private BBCode() {
|
||||
}
|
||||
|
||||
/** Converts BBCode to an HTML fragment (no surrounding {@code <html>} element). */
|
||||
/** Converts BBCode to an HTML fragment (no surrounding {@code <html>} element), pictures as links. */
|
||||
public static String toHtml(String input) {
|
||||
return toHtml(input, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param images whether {@code [img]} becomes an {@code <img>}, as TeamSpeak shows it in
|
||||
* descriptions, rather than a link to the picture, as in chat
|
||||
*/
|
||||
public static String toHtml(String input, boolean images) {
|
||||
if (input == null || input.isEmpty()) return "";
|
||||
String src = BARE_IMG_TAG.matcher(BARE_URL_TAG.matcher(input).replaceAll("[url=$1]$1[/url]"))
|
||||
.replaceAll("[url=$1]$1[/url]");
|
||||
String src = BARE_URL_TAG.matcher(input).replaceAll("[url=$1]$1[/url]");
|
||||
if (!images) src = BARE_IMG_TAG.matcher(src).replaceAll("[url=$1]$1[/url]");
|
||||
|
||||
StringBuilder out = new StringBuilder();
|
||||
Deque<Open> open = new ArrayDeque<>();
|
||||
@@ -65,6 +77,13 @@ public final class BBCode {
|
||||
text(out, src.substring(lb), open);
|
||||
break;
|
||||
}
|
||||
if (images && src.substring(lb + 1, rb).equalsIgnoreCase("img")) {
|
||||
int end = image(out, src, rb + 1);
|
||||
if (end > 0) {
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!emitTag(out, src.substring(lb + 1, rb), open)) {
|
||||
text(out, src.substring(lb, rb + 1), open);
|
||||
}
|
||||
@@ -102,6 +121,16 @@ public final class BBCode {
|
||||
return out.append(escape(input.substring(last))).toString();
|
||||
}
|
||||
|
||||
/** A long web address as its host and file name, for a link's label where room is short. */
|
||||
public static String shortAddress(String url) {
|
||||
if (url.length() <= MAX_ADDRESS) return url;
|
||||
int host = url.indexOf("://");
|
||||
int path = host < 0 ? -1 : url.indexOf('/', host + 3);
|
||||
int file = url.lastIndexOf('/');
|
||||
if (path < 0 || file <= path) return url;
|
||||
return url.substring(host + 3, path) + "/…" + url.substring(file);
|
||||
}
|
||||
|
||||
/** Escapes plain text for HTML without interpreting any BBCode. */
|
||||
public static String escape(String s) {
|
||||
if (s == null) return "";
|
||||
@@ -109,6 +138,20 @@ public final class BBCode {
|
||||
.replace("\"", """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the picture whose address starts at {@code from}, up to its {@code [/img]}.
|
||||
*
|
||||
* @return where the text goes on after it, or -1 when there is no web address to show
|
||||
*/
|
||||
private static int image(StringBuilder out, String src, int from) {
|
||||
Matcher close = IMG_CLOSE.matcher(src);
|
||||
if (!close.find(from)) return -1;
|
||||
String url = src.substring(from, close.start()).trim();
|
||||
if (!IMAGE_URL.matcher(url).matches()) return -1;
|
||||
out.append("<img src=\"").append(escape(url)).append("\">");
|
||||
return close.end();
|
||||
}
|
||||
|
||||
/** True once the tag was recognised and written; false to render it literally. */
|
||||
private static boolean emitTag(StringBuilder out, String tag, Deque<Open> open) {
|
||||
if (tag.isEmpty()) return false;
|
||||
@@ -171,6 +214,7 @@ public final class BBCode {
|
||||
if (!SAFE_URL.matcher(value).matches()) return false;
|
||||
return push(out, open, name, link(value), linkClose(value));
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ts3client.text;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
/** The block tags TeamSpeak's own client renders in descriptions. */
|
||||
class BBCodeTagsTest {
|
||||
@@ -31,4 +32,36 @@ class BBCodeTagsTest {
|
||||
BBCode.toHtml("[table][tr][th]h[/th][td]d[/td][/tr][/table]"));
|
||||
assertEquals("[td]loose[/td]", BBCode.toHtml("[td]loose[/td]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsPicturesOnlyWhereAsked() {
|
||||
String bb = "[img]https://example.com/a.png[/img]";
|
||||
assertEquals("<img src=\"https://example.com/a.png\">", BBCode.toHtml(bb, true));
|
||||
assertEquals("<a class=\"extlink\" href=\"https://example.com/a.png\">https://example.com/a.png</a>",
|
||||
BBCode.toHtml(bb));
|
||||
}
|
||||
|
||||
@Test
|
||||
void linksPicturesInsideALink() {
|
||||
assertEquals("<a class=\"extlink\" href=\"https://x.org\"><img src=\"http://x.org/b.gif\"></a>",
|
||||
BBCode.toHtml("[url=https://x.org][img]http://x.org/b.gif[/img][/url]", true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsNoPictureFromElsewhereThanTheWeb() {
|
||||
assertEquals("[img]file:///etc/passwd[/img]", BBCode.toHtml("[img]file:///etc/passwd[/img]", true));
|
||||
assertFalse(BBCode.toHtml("[img=https://x.org/a.png]", true).contains("<img"), "only [img]…[/img] is a picture");
|
||||
}
|
||||
|
||||
@Test
|
||||
void escapesThePictureAddress() {
|
||||
assertEquals("<img src=\"https://x.org/a.png?a=1&b=2\">", BBCode.toHtml("[img]https://x.org/a.png?a=1&b=2[/img]", true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortensLongAddressesToHostAndFile() {
|
||||
assertEquals("https://x.org/a.png", BBCode.shortAddress("https://x.org/a.png"));
|
||||
assertEquals("upload.wikimedia.org/…/250px-Example.png", BBCode.shortAddress(
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/Example.png/250px-Example.png"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.Insets;
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ final class ChatLogsPanel extends FormPanel {
|
||||
private final JCheckBox enabled = new JCheckBox("Keep chat logs");
|
||||
private final JTextField folder = new JTextField(18);
|
||||
private final JButton browse = new JButton("Browse…");
|
||||
private final JCheckBox pictures = new JCheckBox("Show pictures in descriptions");
|
||||
|
||||
ChatLogsPanel(Settings settings) {
|
||||
this.settings = settings;
|
||||
@@ -50,9 +52,17 @@ final class ChatLogsPanel extends FormPanel {
|
||||
|
||||
c.gridy = 2;
|
||||
c.gridx = 1;
|
||||
c.weighty = 1;
|
||||
c.anchor = GridBagConstraints.NORTHWEST;
|
||||
add(new JLabel("Takes effect on the next connect."), c);
|
||||
|
||||
pictures.setSelected(settings.descriptionPictures);
|
||||
pictures.setToolTipText("Off: only links to them, nothing fetched");
|
||||
c.gridy = 3;
|
||||
c.gridx = 0;
|
||||
c.gridwidth = 2;
|
||||
c.weighty = 1;
|
||||
c.insets = new Insets(UIScale.scale(12), c.insets.left, c.insets.bottom, c.insets.right);
|
||||
add(pictures, c);
|
||||
updateEnabled();
|
||||
}
|
||||
|
||||
@@ -76,5 +86,6 @@ final class ChatLogsPanel extends FormPanel {
|
||||
void apply() {
|
||||
settings.chatLogs = enabled.isSelected();
|
||||
settings.chatLogDir = folder.getText().trim();
|
||||
settings.descriptionPictures = pictures.isSelected();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.net.PictureDownloads;
|
||||
import com.ts3client.text.BBCode;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.Dimension;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Shows the pictures of description HTML as the info views can: from the downloads' files,
|
||||
* narrowed to fit, with a loading icon while one comes. With pictures turned off, or when one
|
||||
* cannot be had, it is a link to the picture behind a picture icon instead.
|
||||
*/
|
||||
final class DescriptionPictures {
|
||||
|
||||
/** A picture, and the links it may sit in. */
|
||||
private static final Pattern TAG = Pattern.compile(
|
||||
"<img src=\"([^\"]*)\">|<a [^>]*href=\"([^\"]*)\"[^>]*>|</a>", Pattern.CASE_INSENSITIVE);
|
||||
private static final int ICON = 16;
|
||||
|
||||
private final PictureDownloads downloads;
|
||||
private final Settings settings;
|
||||
private final Map<File, Dimension> sizes = new ConcurrentHashMap<>();
|
||||
|
||||
DescriptionPictures(PictureDownloads downloads, Settings settings) {
|
||||
this.downloads = downloads;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
static boolean hasPictures(String html) {
|
||||
return html.contains("<img src=\"");
|
||||
}
|
||||
|
||||
/** @param maxWidth the widest a picture may be drawn */
|
||||
String html(String html, int maxWidth) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
Matcher m = TAG.matcher(html);
|
||||
String anchor = null;
|
||||
String href = null;
|
||||
int last = 0;
|
||||
while (m.find()) {
|
||||
out.append(html, last, m.start());
|
||||
last = m.end();
|
||||
if (m.group(1) != null) {
|
||||
out.append(picture(unescape(m.group(1)), anchor, href == null ? null : unescape(href), maxWidth));
|
||||
} else if (m.group(2) != null) {
|
||||
anchor = m.group();
|
||||
href = m.group(2);
|
||||
out.append(anchor);
|
||||
} else {
|
||||
anchor = null;
|
||||
href = null;
|
||||
out.append(m.group());
|
||||
}
|
||||
}
|
||||
return out.append(html.substring(last)).toString();
|
||||
}
|
||||
|
||||
private String picture(String url, String anchor, String href, int maxWidth) {
|
||||
if (!settings.descriptionPictures) return link("ICONVIEWER", url, anchor, href);
|
||||
File file = downloads.get(url);
|
||||
if (file == null) {
|
||||
return downloads.failed(url) ? link("BROKEN_IMAGE", url, anchor, href) : icon("LOADING_IMAGE");
|
||||
}
|
||||
Dimension size = sizes.computeIfAbsent(file, DescriptionPictures::read);
|
||||
if (size.width <= 0 || size.height <= 0) return link("BROKEN_IMAGE", url, anchor, href);
|
||||
int width = Math.min(UIScale.scale(size.width), Math.max(1, maxWidth));
|
||||
int height = (int) ((long) size.height * width / size.width);
|
||||
// Swing frames a picture inside a link unless told not to.
|
||||
return "<img src='" + file.toURI() + "' width='" + width + "' height='" + height + "' border='0'>";
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture's address as a link behind {@code iconKey}, shortened: Swing never breaks a
|
||||
* long one, so it would widen the view past its edge. Inside another link, that link is
|
||||
* closed around it and shown after it, so it is not lost with the picture that carried it.
|
||||
*/
|
||||
private static String link(String iconKey, String url, String anchor, String href) {
|
||||
String own = icon(iconKey) + "<a class=\"" + BBCode.linkClass(url) + "\" href=\"" + BBCode.escape(url) + "\">"
|
||||
+ BBCode.escape(BBCode.shortAddress(url)) + "</a>";
|
||||
if (anchor == null) return own;
|
||||
String outer = href.equals(url) ? "" : " " + anchor + BBCode.escape(href) + "</a>";
|
||||
return "</a>" + own + outer + anchor;
|
||||
}
|
||||
|
||||
private static String icon(String key) {
|
||||
int size = UIScale.scale(ICON);
|
||||
File file = IconFiles.of(key, size);
|
||||
if (file == null) return "";
|
||||
return "<img src='" + file.toURI() + "' width='" + size + "' height='" + size + "'> ";
|
||||
}
|
||||
|
||||
/** The picture's size in pixels, read from its header; zero when it is no picture Swing can show. */
|
||||
private static Dimension read(File file) {
|
||||
try (ImageInputStream in = ImageIO.createImageInputStream(file)) {
|
||||
Iterator<ImageReader> readers = in == null ? null : ImageIO.getImageReaders(in);
|
||||
if (readers == null || !readers.hasNext()) return new Dimension();
|
||||
ImageReader reader = readers.next();
|
||||
try {
|
||||
reader.setInput(in);
|
||||
return new Dimension(reader.getWidth(0), reader.getHeight(0));
|
||||
} finally {
|
||||
reader.dispose();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
return new Dimension();
|
||||
}
|
||||
}
|
||||
|
||||
private static String unescape(String s) {
|
||||
return s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.swing.ImageIcon;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Icons of the active pack as PNG files, for HTML views, which only show pictures by URL.
|
||||
* A pack change gives the theme new icons, and so new files.
|
||||
*/
|
||||
final class IconFiles {
|
||||
|
||||
private static final Map<String, File> FILES = new ConcurrentHashMap<>();
|
||||
private static File dir;
|
||||
|
||||
private IconFiles() {
|
||||
}
|
||||
|
||||
/** @return the icon's file, or {@code null} if no pack has it or it could not be written */
|
||||
static File of(String key, int size) {
|
||||
ImageIcon icon = IconTheme.icon(key, size);
|
||||
if (icon == null) return null;
|
||||
String name = key + '_' + size + '_' + Integer.toHexString(System.identityHashCode(icon)) + ".png";
|
||||
File file = FILES.computeIfAbsent(name, n -> write(icon, n));
|
||||
return file.isFile() ? file : null;
|
||||
}
|
||||
|
||||
private static File write(ImageIcon icon, String name) {
|
||||
try {
|
||||
File file = new File(dir(), name);
|
||||
BufferedImage image = new BufferedImage(icon.getIconWidth(), icon.getIconHeight(), BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
icon.paintIcon(null, g, 0, 0);
|
||||
g.dispose();
|
||||
ImageIO.write(image, "png", file);
|
||||
file.deleteOnExit();
|
||||
return file;
|
||||
} catch (IOException e) {
|
||||
return new File(name); // never a file, so asked for again next time
|
||||
}
|
||||
}
|
||||
|
||||
private static synchronized File dir() throws IOException {
|
||||
if (dir == null) {
|
||||
dir = Files.createTempDirectory("ts3client-icons").toFile();
|
||||
dir.deleteOnExit();
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.contacts.Contact;
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.Details;
|
||||
import com.ts3client.net.Group;
|
||||
import com.ts3client.net.IconRepository;
|
||||
import com.ts3client.net.PictureDownloads;
|
||||
import com.ts3client.net.ServerModel;
|
||||
import com.ts3client.net.avatar.Avatar;
|
||||
import com.ts3client.text.BBCode;
|
||||
@@ -18,9 +20,11 @@ import javax.swing.JLabel;
|
||||
import javax.swing.JLayeredPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Point;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.event.ComponentAdapter;
|
||||
import java.awt.event.ComponentEvent;
|
||||
@@ -49,6 +53,9 @@ public final class InfoPanel extends JPanel {
|
||||
|
||||
private static final int AVATAR_MIN_SIZE = 64;
|
||||
private static final int AVATAR_BOX_STEP = 16;
|
||||
/** Pictures in a chat tab have no panel to fit, so they get this much. */
|
||||
private static final int CHAT_TAB_PICTURE_WIDTH = 480;
|
||||
private static final int PICTURE_WIDTH_STEP = 32;
|
||||
|
||||
private final JEditorPane pane = HtmlStyles.pane("font-family:sans-serif; font-size:11px;");
|
||||
private final JScrollPane scroll = new JScrollPane(pane);
|
||||
@@ -66,9 +73,19 @@ public final class InfoPanel extends JPanel {
|
||||
private boolean inChatTab;
|
||||
/** The avatar box the current HTML was built for; a resize that changes it re-renders. */
|
||||
private int renderedAvatarBox;
|
||||
private final PictureDownloads downloads;
|
||||
private final DescriptionPictures pictures;
|
||||
/** Whether the description shown has pictures, which re-render it as they come or the panel resizes. */
|
||||
private boolean showsPictures;
|
||||
private int renderedPictureWidth;
|
||||
private final Runnable onPicture = () -> SwingUtilities.invokeLater(() -> {
|
||||
if (showsPictures) keepingScroll(this::render);
|
||||
});
|
||||
|
||||
public InfoPanel() {
|
||||
public InfoPanel(Settings settings, PictureDownloads downloads) {
|
||||
super(new BorderLayout());
|
||||
this.downloads = downloads;
|
||||
this.pictures = new DescriptionPictures(downloads, settings);
|
||||
pane.setBorder(new ScaledEmptyBorder(6, 8, 6, 8));
|
||||
pane.addHyperlinkListener(e -> {
|
||||
if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return;
|
||||
@@ -78,7 +95,10 @@ public final class InfoPanel extends JPanel {
|
||||
scroll.getViewport().addComponentListener(new ComponentAdapter() {
|
||||
@Override
|
||||
public void componentResized(ComponentEvent e) {
|
||||
if (shownAvatar != null && !inChatTab && avatarBox() != renderedAvatarBox) render();
|
||||
if (inChatTab) return;
|
||||
boolean avatar = shownAvatar != null && avatarBox() != renderedAvatarBox;
|
||||
boolean pictures = showsPictures && pictureWidth() != renderedPictureWidth;
|
||||
if (avatar || pictures) keepingScroll(InfoPanel.this::render);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -129,6 +149,25 @@ public final class InfoPanel extends JPanel {
|
||||
clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addNotify() {
|
||||
super.addNotify();
|
||||
downloads.addListener(onPicture);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeNotify() {
|
||||
downloads.removeListener(onPicture);
|
||||
super.removeNotify();
|
||||
}
|
||||
|
||||
/** Runs {@code change}, which renders again, without the view jumping back to the top. */
|
||||
private void keepingScroll(Runnable change) {
|
||||
Point at = scroll.getViewport().getViewPosition();
|
||||
change.run();
|
||||
SwingUtilities.invokeLater(() -> scroll.getViewport().setViewPosition(at));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateUI() {
|
||||
super.updateUI();
|
||||
@@ -201,6 +240,7 @@ public final class InfoPanel extends JPanel {
|
||||
}
|
||||
|
||||
private String body() {
|
||||
showsPictures = false;
|
||||
if (shownServer != null) return html(Details.server(shownModel, shownServer), "", null, null);
|
||||
if (shownChannel != null) return html(Details.channel(shownChannel), "", null, "No description.");
|
||||
if (shownClient != null) {
|
||||
@@ -224,7 +264,7 @@ public final class InfoPanel extends JPanel {
|
||||
return Math.max(UIScale.scale(AVATAR_MIN_SIZE), Math.min(max, box));
|
||||
}
|
||||
|
||||
private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact,
|
||||
private String clientBody(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact,
|
||||
Avatar avatar, int avatarBox) {
|
||||
String details = clientDetails(cl, model, icons, contact);
|
||||
String picture = AvatarHtml.imgTag(avatar, avatarBox);
|
||||
@@ -236,7 +276,7 @@ public final class InfoPanel extends JPanel {
|
||||
+ "</tr></table>";
|
||||
}
|
||||
|
||||
private static String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) {
|
||||
private String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) {
|
||||
String you = cl.self ? " <span class='muted'>(you)</span>" : "";
|
||||
return html(Details.client(cl, model, contact), you, icons, null);
|
||||
}
|
||||
@@ -246,12 +286,12 @@ public final class InfoPanel extends JPanel {
|
||||
* @param icons for the icons of the groups a field names; null for none
|
||||
* @param none what to say when there is no description; null to say nothing
|
||||
*/
|
||||
private static String html(Details details, String titleSuffix, IconRepository icons, String none) {
|
||||
private String html(Details details, String titleSuffix, IconRepository icons, String none) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(heading(esc(details.title()) + titleSuffix));
|
||||
for (Details.Field f : details.fields()) row(sb, esc(f.label()), value(f, icons));
|
||||
if (!details.description().isEmpty()) {
|
||||
sb.append("<hr><div>").append(multiline(details.description())).append("</div>");
|
||||
sb.append("<hr><div>").append(description(details.description())).append("</div>");
|
||||
} else if (details.pending()) {
|
||||
sb.append("<hr><i class='muted'>Loading description…</i>");
|
||||
} else if (none != null) {
|
||||
@@ -296,9 +336,24 @@ public final class InfoPanel extends JPanel {
|
||||
.append(label).append(":</span> ").append(value).append("</div>");
|
||||
}
|
||||
|
||||
/** Descriptions may carry BBCode markup. */
|
||||
private static String multiline(String text) {
|
||||
return BBCode.toHtml(text);
|
||||
/** Descriptions may carry BBCode markup, pictures included. */
|
||||
private String description(String bbcode) {
|
||||
String html = BBCode.toHtml(bbcode, true);
|
||||
if (!DescriptionPictures.hasPictures(html)) return html;
|
||||
showsPictures = true;
|
||||
renderedPictureWidth = pictureWidth();
|
||||
return pictures.html(html, renderedPictureWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* How wide a picture may be: the panel's width inside its padding, in steps so that
|
||||
* dragging a divider does not rebuild the HTML on every pixel.
|
||||
*/
|
||||
private int pictureWidth() {
|
||||
if (inChatTab) return UIScale.scale(CHAT_TAB_PICTURE_WIDTH);
|
||||
int step = UIScale.scale(PICTURE_WIDTH_STEP);
|
||||
int width = scroll.getViewport().getWidth() - UIScale.scale(24);
|
||||
return Math.max(step, width / step * step);
|
||||
}
|
||||
|
||||
private static String esc(String s) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ts3client.ui;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.audio.AudioBackend;
|
||||
import com.ts3client.audio.desktop.DesktopAudioBackend;
|
||||
import com.ts3client.config.AppDirs;
|
||||
import com.ts3client.config.AwayMessages;
|
||||
import com.ts3client.config.BanReasonPresets;
|
||||
import com.ts3client.config.Bookmark;
|
||||
@@ -13,6 +14,7 @@ import com.ts3client.myts.MyTeamSpeakLogin;
|
||||
import com.ts3client.teamspeak.TeamSpeakImporter;
|
||||
import com.ts3client.contacts.ContactStore;
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.PictureDownloads;
|
||||
import com.ts3client.session.SelfState;
|
||||
import com.ts3client.session.ServerSession;
|
||||
import com.ts3client.session.Sessions;
|
||||
@@ -33,6 +35,7 @@ import javax.swing.JPopupMenu;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.io.File;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.GridLayout;
|
||||
@@ -62,6 +65,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
/** Friends and blocked clients, shared by every connection. */
|
||||
private final ContactStore contacts = ContactStore.load();
|
||||
private final AudioBackend audio = new DesktopAudioBackend();
|
||||
private final PictureDownloads pictures = new PictureDownloads(new File(AppDirs.profile(), "pictures"), true);
|
||||
/** Sound pack playback, shared by every connection. */
|
||||
private final SoundNotifier sounds;
|
||||
private final SoundPlayer soundPlayer;
|
||||
@@ -360,7 +364,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
// ---- tab management ----
|
||||
|
||||
private ServerTab newTab() {
|
||||
ServerTab tab = new ServerTab(this, settings, identities, audio, sounds, contacts);
|
||||
ServerTab tab = new ServerTab(this, settings, identities, audio, sounds, contacts, pictures);
|
||||
tabs.add(tab);
|
||||
sessions.add(tab.session());
|
||||
tabPane.addTab(tab);
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.ts3client.config.Settings;
|
||||
import com.ts3client.contacts.ContactStore;
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ConnectionListener.ChatScope;
|
||||
import com.ts3client.net.PictureDownloads;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
import com.ts3client.session.ServerSession;
|
||||
import com.ts3client.sound.SoundNotifier;
|
||||
@@ -38,7 +39,7 @@ final class ServerTab {
|
||||
private final ServerTabTreeActions treeActions;
|
||||
private final ServerTreePanel treePanel;
|
||||
private final ChatPanel chatPanel;
|
||||
private final InfoPanel infoPanel = new InfoPanel();
|
||||
private final InfoPanel infoPanel;
|
||||
private final JComponent component;
|
||||
private JSplitPane leftColumn;
|
||||
private int normalDividerSize;
|
||||
@@ -50,9 +51,10 @@ final class ServerTab {
|
||||
private BanListDialog banList;
|
||||
|
||||
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
|
||||
SoundNotifier sounds, ContactStore contacts) {
|
||||
SoundNotifier sounds, ContactStore contacts, PictureDownloads pictures) {
|
||||
this.host = host;
|
||||
this.settings = settings;
|
||||
this.infoPanel = new InfoPanel(settings, pictures);
|
||||
|
||||
// ServerTabConnectionEvents must exist before the session (which needs a listener
|
||||
// up front), and the session must exist before the tree/chat panels and the other
|
||||
|
||||
Reference in New Issue
Block a user