fix(media): code review — dead code removal, perf fixes, bug fixes

- Remove unused: MediaAspectRatioCache, VideoPlayerState, resetZoom,
  onDoubleClick, delete/headUpload/createDeleteAuth, encryptAndUpload,
  EncryptedUploadResult, unused options params, formatAudioTime dupe
- Fix VlcjPlayerPool.release() accumulating stale event listeners
- Fix SaveMediaAction FileDialog running on IO instead of EDT
- Pre-allocate video frame ByteArray to avoid ~240MB/s GC pressure
- Pool audio players in VlcjPlayerPool instead of factory-per-instance
- Remove listener in onDispose before returning player to pool
- Fix acquire() race condition by moving poll inside synchronized
- Reduce memory cache to 15%/256MB with weak references
- Parallelize server health checks
- Remove redundant Content-Type/Content-Length headers
- Deduplicate BlurHashFetcher bitmap conversion via bufferedImageToSkiaBitmap
- Hoist audioExtensions to top-level constant, rename allMediaUrls
- Remove nonfunctional Save button and dead decryptedBytes state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-16 11:58:41 +02:00
parent d5facd90df
commit e654d48cdf
17 changed files with 143 additions and 346 deletions
@@ -1,36 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.model
import androidx.collection.LruCache
object MediaAspectRatioCache {
private val cache = LruCache<String, Float>(1000)
fun get(url: String): Float? = cache[url]
fun put(
url: String,
ratio: Float,
) {
cache.put(url, ratio)
}
}
@@ -42,7 +42,6 @@ import java.awt.image.BufferedImage
@Stable
class DesktopBase64Fetcher(
private val options: Options,
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult? =
@@ -64,7 +63,7 @@ class DesktopBase64Fetcher(
imageLoader: ImageLoader,
): Fetcher? =
if (data.scheme == "data") {
DesktopBase64Fetcher(options, data)
DesktopBase64Fetcher(data)
} else {
null
}
@@ -31,9 +31,6 @@ import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ImageInfo
data class BlurhashWrapper(
val blurhash: String,
@@ -41,23 +38,13 @@ data class BlurhashWrapper(
@Stable
class DesktopBlurHashFetcher(
private val options: Options,
private val data: BlurhashWrapper,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val hash = data.blurhash
val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
val bufferedImage = platformImage.toBufferedImage()
val w = bufferedImage.width
val h = bufferedImage.height
val pixels = IntArray(w * h)
bufferedImage.getRGB(0, 0, w, h, pixels, 0, w)
val bitmap = Bitmap()
bitmap.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
bitmap.installPixels(convertArgbToBgra(pixels))
bitmap.setImmutable()
val bitmap = bufferedImageToSkiaBitmap(bufferedImage)
return ImageFetchResult(
image = bitmap.asImage(true),
@@ -71,7 +58,7 @@ class DesktopBlurHashFetcher(
data: BlurhashWrapper,
options: Options,
imageLoader: ImageLoader,
): Fetcher = DesktopBlurHashFetcher(options, data)
): Fetcher = DesktopBlurHashFetcher(data)
}
object BKeyer : Keyer<BlurhashWrapper> {
@@ -54,11 +54,11 @@ object DesktopImageLoaderSetup {
private fun newMemoryCache(): MemoryCache {
val maxMemory = Runtime.getRuntime().maxMemory()
val cacheSize = (maxMemory * 0.25).toLong().coerceAtMost(512L * 1024 * 1024)
val cacheSize = (maxMemory * 0.15).toLong().coerceAtMost(256L * 1024 * 1024)
return MemoryCache
.Builder()
.maxSizeBytes(cacheSize)
.strongReferencesEnabled(true)
.strongReferencesEnabled(false)
.build()
}
@@ -20,82 +20,18 @@
*/
package com.vitorpamplona.amethyst.desktop.service.media
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopBlossomClient
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopMediaMetadata
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
/**
* Handles encryption/decryption of media files for NIP-17 DMs.
* Handles decryption of media files for NIP-17 DMs.
* Uses AESGCM cipher from quartz (commonMain).
*/
object EncryptedMediaService {
private val httpClient = OkHttpClient()
private val blossomClient = DesktopBlossomClient()
data class EncryptedUploadResult(
val url: String,
val cipher: AESGCM,
val mimeType: String?,
val hash: String?,
val size: Int,
val dimensions: Pair<Int, Int>?,
val blurhash: String?,
)
/**
* Encrypt a file and upload to Blossom.
* Returns the encrypted upload result with cipher details.
*/
suspend fun encryptAndUpload(
file: File,
serverBaseUrl: String,
authHeader: String?,
): EncryptedUploadResult =
withContext(Dispatchers.IO) {
val metadata = DesktopMediaMetadata.compute(file)
val cipher = AESGCM()
// Read file bytes and encrypt
val plainBytes = file.readBytes()
val encryptedBytes = cipher.encrypt(plainBytes)
// Write encrypted bytes to temp file for upload
val tempFile = File.createTempFile("encrypted_", ".enc")
tempFile.deleteOnExit()
tempFile.writeBytes(encryptedBytes)
try {
val result =
blossomClient.upload(
file = tempFile,
contentType = "application/octet-stream",
serverBaseUrl = serverBaseUrl,
authHeader = authHeader,
)
EncryptedUploadResult(
url = result.url ?: throw IllegalStateException("No URL in upload result"),
cipher = cipher,
mimeType = metadata.mimeType,
hash = metadata.sha256,
size = plainBytes.size,
dimensions =
if (metadata.width != null && metadata.height != null) {
metadata.width to metadata.height
} else {
null
},
blurhash = metadata.blurhash,
)
} finally {
tempFile.delete()
}
}
/**
* Download and decrypt an encrypted file from a URL.
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.desktop.service.media
import uk.co.caprica.vlcj.factory.MediaPlayerFactory
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
@@ -40,12 +40,17 @@ object VlcjPlayerPool {
private val available = AtomicBoolean(false)
private var factory: MediaPlayerFactory? = null
// Strong references to ALL created players (prevents GC crash)
// Video player pool
private val allPlayers = mutableListOf<EmbeddedMediaPlayer>()
private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
private const val MAX_POOL_SIZE = 3
// Audio player pool (shared factory with --no-video)
private var audioFactory: MediaPlayerFactory? = null
private val allAudioPlayers = mutableListOf<MediaPlayer>()
private val idleAudioPlayers = ConcurrentLinkedQueue<MediaPlayer>()
private const val MAX_AUDIO_POOL_SIZE = 5
/**
* Initialize the pool. Returns false if VLC is not installed.
*/
@@ -76,18 +81,15 @@ object VlcjPlayerPool {
}
/**
* Acquire a player from the pool or create a new one.
* Acquire a video player from the pool or create a new one.
* Returns null if VLC is not available or pool is at capacity.
*/
fun acquire(): EmbeddedMediaPlayer? {
if (!available.get()) return null
val f = factory ?: return null
// Reuse idle player
idlePlayers.poll()?.let { return it }
// Create new if under limit
synchronized(allPlayers) {
idlePlayers.poll()?.let { return it }
if (allPlayers.size >= MAX_POOL_SIZE) return null
return try {
val player = f.mediaPlayers().newEmbeddedMediaPlayer()
@@ -100,21 +102,57 @@ object VlcjPlayerPool {
}
/**
* Return a player to the pool for reuse. Stops playback first.
* Acquire an audio-only player from the pool.
* Uses a separate factory with --no-video for efficiency.
*/
fun acquireAudioPlayer(): MediaPlayer? {
if (!init()) return null
synchronized(allAudioPlayers) {
idleAudioPlayers.poll()?.let { return it }
if (allAudioPlayers.size >= MAX_AUDIO_POOL_SIZE) return null
val af =
audioFactory ?: try {
MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it }
} catch (_: Exception) {
return null
}
return try {
val player = af.mediaPlayers().newMediaPlayer()
allAudioPlayers.add(player)
player
} catch (_: Exception) {
null
}
}
}
/**
* Return a video player to the pool for reuse.
*/
fun release(player: EmbeddedMediaPlayer) {
try {
player.controls().stop()
// Remove any event listeners to prevent stale callbacks
player.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() {},
)
idlePlayers.offer(player)
} catch (_: Exception) {
// Player may already be disposed
}
}
/**
* Return an audio player to the pool for reuse.
*/
fun releaseAudioPlayer(player: MediaPlayer) {
try {
player.controls().stop()
idleAudioPlayers.offer(player)
} catch (_: Exception) {
// Player may already be disposed
}
}
/**
* Shut down the entire pool. Call on app exit.
*/
@@ -131,12 +169,30 @@ object VlcjPlayerPool {
}
allPlayers.clear()
}
synchronized(allAudioPlayers) {
idleAudioPlayers.clear()
for (player in allAudioPlayers) {
try {
player.controls().stop()
player.release()
} catch (_: Exception) {
// Ignore
}
}
allAudioPlayers.clear()
}
try {
factory?.release()
} catch (_: Exception) {
// Ignore
}
try {
audioFactory?.release()
} catch (_: Exception) {
// Ignore
}
factory = null
audioFactory = null
available.set(false)
}
}
@@ -36,15 +36,6 @@ object DesktopBlossomAuth {
return encodeAuthHeader(event)
}
suspend fun createDeleteAuth(
hash: HexKey,
alt: String,
signer: NostrSigner,
): String {
val event = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer)
return encodeAuthHeader(event)
}
fun encodeAuthHeader(event: BlossomAuthorizationEvent): String {
val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray())
return "Nostr $b64"
@@ -59,8 +59,6 @@ class DesktopBlossomClient(
.Builder()
.url(apiUrl)
.put(requestBody)
.addHeader("Content-Length", file.length().toString())
.addHeader("Content-Type", contentType)
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
@@ -73,41 +71,4 @@ class DesktopBlossomClient(
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
}
}
suspend fun delete(
hash: String,
serverBaseUrl: String,
authHeader: String?,
): Boolean =
withContext(Dispatchers.IO) {
val apiUrl = serverBaseUrl.removeSuffix("/") + "/$hash"
val requestBuilder = Request.Builder().url(apiUrl).delete()
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use { it.isSuccessful }
}
suspend fun headUpload(
contentType: String,
contentLength: Long,
sha256: String,
serverBaseUrl: String,
authHeader: String?,
): Boolean =
withContext(Dispatchers.IO) {
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
val requestBuilder =
Request
.Builder()
.url(apiUrl)
.head()
.addHeader("X-Content-Type", contentType)
.addHeader("X-Content-Length", contentLength.toString())
.addHeader("X-SHA-256", sha256)
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use { it.isSuccessful }
}
}
@@ -39,7 +39,6 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -71,7 +70,6 @@ fun ChatFileAttachment(
val isImage = mimeType?.startsWith("image/") == true
var decryptedImage by remember { mutableStateOf<ImageBitmap?>(null) }
var decryptedBytes by remember { mutableStateOf<ByteArray?>(null) }
var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
@@ -81,7 +79,6 @@ fun ChatFileAttachment(
isLoading = true
try {
val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce)
decryptedBytes = bytes
withContext(Dispatchers.Default) {
val skImage = SkiaImage.makeFromEncoded(bytes)
decryptedImage = skImage.toComposeImageBitmap()
@@ -173,18 +170,6 @@ fun ChatFileAttachment(
}
}
}
// Save button for decrypted files
if (decryptedBytes != null) {
TextButton(
onClick = {
// For encrypted files, we'd need to save decrypted bytes
// This is handled through the save action
},
) {
Text("Save", style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
@@ -49,14 +49,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import kotlinx.coroutines.delay
import uk.co.caprica.vlcj.factory.MediaPlayerFactory
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
/**
* Audio-only player using VLCJ with no video surface.
* Creates its own MediaPlayerFactory with "--no-video" flag.
* Audio-only player using VLCJ with pooled audio players.
* Uses VlcjPlayerPool's shared audio factory instead of creating one per instance.
*/
@Composable
fun AudioPlayer(
@@ -71,17 +71,13 @@ fun AudioPlayer(
var player by remember { mutableStateOf<MediaPlayer?>(null) }
DisposableEffect(url) {
val factory =
try {
MediaPlayerFactory("--no-video", "--no-xlib")
} catch (_: Exception) {
vlcAvailable = false
return@DisposableEffect onDispose {}
}
val mp = VlcjPlayerPool.acquireAudioPlayer()
if (mp == null) {
vlcAvailable = false
return@DisposableEffect onDispose {}
}
val mp = factory.mediaPlayers().newMediaPlayer()
mp.events().addMediaPlayerEventListener(
val listener =
object : MediaPlayerEventAdapter() {
override fun playing(mediaPlayer: MediaPlayer) {
isPlaying = true
@@ -109,24 +105,15 @@ fun AudioPlayer(
position = 0f
currentTime = 0L
}
},
)
}
mp.events().addMediaPlayerEventListener(listener)
player = mp
onDispose {
player = null
try {
mp.controls().stop()
mp.release()
} catch (_: Exception) {
// Ignore
}
try {
factory.release()
} catch (_: Exception) {
// Ignore
}
mp.events().removeMediaPlayerEventListener(listener)
VlcjPlayerPool.releaseAudioPlayer(mp)
}
}
@@ -192,7 +179,7 @@ fun AudioPlayer(
}
Text(
text = formatAudioTime(currentTime),
text = formatTime(currentTime),
style = MaterialTheme.typography.labelSmall,
)
@@ -203,15 +190,8 @@ fun AudioPlayer(
)
Text(
text = formatAudioTime(duration),
text = formatTime(duration),
style = MaterialTheme.typography.labelSmall,
)
}
}
private fun formatAudioTime(millis: Long): String {
val totalSeconds = millis / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "%d:%02d".format(minutes, seconds)
}
@@ -58,13 +58,6 @@ import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32Buffe
import java.nio.ByteBuffer
import org.jetbrains.skia.Image as SkiaImage
data class VideoPlayerState(
val isPlaying: Boolean = false,
val position: Float = 0f,
val duration: Long = 0L,
val currentTime: Long = 0L,
)
@Composable
fun DesktopVideoPlayer(
url: String,
@@ -93,10 +86,9 @@ fun DesktopVideoPlayer(
return@DisposableEffect onDispose {}
}
// Skia bitmap for DirectRendering
// Skia bitmap for DirectRendering — pre-allocated to avoid per-frame GC
var skBitmap: Bitmap? = null
var videoWidth = 0
var videoHeight = 0
var pixelBytes: ByteArray? = null
val bufferFormatCallback =
object : BufferFormatCallback {
@@ -104,17 +96,15 @@ fun DesktopVideoPlayer(
sourceWidth: Int,
sourceHeight: Int,
): BufferFormat {
videoWidth = sourceWidth
videoHeight = sourceHeight
if (sourceHeight > 0) {
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat()
}
// Allocate Skia bitmap
val bmp = Bitmap()
bmp.allocPixels(
ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL),
)
skBitmap = bmp
pixelBytes = ByteArray(sourceWidth * sourceHeight * 4)
return RV32BufferFormat(sourceWidth, sourceHeight)
}
@@ -126,9 +116,9 @@ fun DesktopVideoPlayer(
val renderCallback =
RenderCallback { _, nativeBuffers, _ ->
val bmp = skBitmap ?: return@RenderCallback
val bytes = pixelBytes ?: return@RenderCallback
val buffer = nativeBuffers[0]
buffer.rewind()
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
bmp.installPixels(bytes)
frame = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
@@ -142,7 +132,7 @@ fun DesktopVideoPlayer(
acquired.videoSurface().set(surface)
acquired.events().addMediaPlayerEventListener(
val listener =
object : MediaPlayerEventAdapter() {
override fun playing(mediaPlayer: MediaPlayer) {
isPlaying = true
@@ -170,8 +160,9 @@ fun DesktopVideoPlayer(
position = 0f
currentTime = 0L
}
},
)
}
acquired.events().addMediaPlayerEventListener(listener)
player = acquired
@@ -183,6 +174,7 @@ fun DesktopVideoPlayer(
onDispose {
player = null
acquired.events().removeMediaPlayerEventListener(listener)
VlcjPlayerPool.release(acquired)
}
}
@@ -38,19 +38,24 @@ object SaveMediaAction {
suspend fun saveMedia(
url: String,
suggestedFilename: String? = null,
): File? =
withContext(Dispatchers.IO) {
val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
): File? {
val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
val dialog =
FileDialog(null as Frame?, "Save Media", FileDialog.SAVE).apply {
file = filename
}
dialog.isVisible = true
// FileDialog must be shown on EDT
val file =
withContext(Dispatchers.Main) {
val dialog =
FileDialog(null as Frame?, "Save Media", FileDialog.SAVE).apply {
this.file = filename
}
dialog.isVisible = true
val dir = dialog.directory ?: return@withContext null
val file = File(dir, dialog.file ?: return@withContext null)
val dir = dialog.directory ?: return@withContext null
File(dir, dialog.file ?: return@withContext null)
} ?: return null
// Download on IO
return withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(url).build()
val response = httpClient.newCall(request).execute()
@@ -67,4 +72,5 @@ object SaveMediaAction {
null
}
}
}
}
@@ -169,7 +169,7 @@ fun VideoControls(
}
}
private fun formatTime(millis: Long): String {
internal fun formatTime(millis: Long): String {
val totalSeconds = millis / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
@@ -42,7 +42,6 @@ import coil3.compose.AsyncImage
fun ZoomableImage(
url: String,
modifier: Modifier = Modifier,
onDoubleClick: (() -> Unit)? = null,
) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
@@ -94,7 +93,3 @@ fun ZoomableImage(
)
}
}
fun resetZoom(onReset: () -> Unit) {
onReset()
}
@@ -57,6 +57,8 @@ import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
private val AUDIO_EXTENSIONS = setOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a")
/**
* Data class for displaying a note card.
*/
@@ -86,27 +88,26 @@ fun NoteCard(
remember(urls) {
urls.withScheme.filter { RichTextParser.isImageUrl(it) }
}
val allMediaUrls =
val videoAndAudioUrls =
remember(urls) {
urls.withScheme.filter { RichTextParser.isVideoUrl(it) }
}
val audioExtensions = setOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a")
val audioUrls =
remember(allMediaUrls) {
allMediaUrls.filter { url ->
remember(videoAndAudioUrls) {
videoAndAudioUrls.filter { url ->
val ext =
url
.substringAfterLast('.', "")
.substringBefore('?')
.lowercase()
ext in audioExtensions
ext in AUDIO_EXTENSIONS
}
}
val videoUrls =
remember(allMediaUrls, audioUrls) {
allMediaUrls - audioUrls.toSet()
remember(videoAndAudioUrls, audioUrls) {
videoAndAudioUrls - audioUrls.toSet()
}
val mediaUrls = remember(imageUrls, allMediaUrls) { (imageUrls + allMediaUrls).toSet() }
val mediaUrls = remember(imageUrls, videoAndAudioUrls) { (imageUrls + videoAndAudioUrls).toSet() }
val strippedContent =
remember(note.content, mediaUrls) {
var text = note.content
@@ -60,6 +60,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@Composable
@@ -74,12 +75,16 @@ fun MediaServerSettings(
val scope = rememberCoroutineScope()
var isChecking by remember { mutableStateOf(false) }
// Check health on first load
// Check health on first load (parallel)
LaunchedEffect(servers.toList()) {
for (server in servers) {
if (server !in serverStatuses) {
val status = ServerHealthCheck.check(server)
serverStatuses[server] = status
coroutineScope {
for (server in servers) {
if (server !in serverStatuses) {
launch {
val status = ServerHealthCheck.check(server)
serverStatuses[server] = status
}
}
}
}
}
@@ -172,10 +177,14 @@ fun MediaServerSettings(
onClick = {
scope.launch {
isChecking = true
for (server in servers) {
serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN
val status = ServerHealthCheck.check(server)
serverStatuses[server] = status
coroutineScope {
for (server in servers) {
launch {
serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN
val status = ServerHealthCheck.check(server)
serverStatuses[server] = status
}
}
}
isChecking = false
}
@@ -35,7 +35,6 @@ import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopBlossomClientTest {
@@ -142,70 +141,6 @@ class DesktopBlossomClientTest {
}
}
@Test
fun deleteSuccessReturnsTrue() =
runTest {
val client = DesktopBlossomClient(mockOkHttp(200))
val result =
client.delete(
hash = "abc123",
serverBaseUrl = "https://blossom.example.com",
authHeader = "Nostr xyz",
)
assertTrue(result)
}
@Test
fun deleteFailureReturnsFalse() =
runTest {
val client = DesktopBlossomClient(mockOkHttp(404))
val result =
client.delete(
hash = "abc123",
serverBaseUrl = "https://blossom.example.com",
authHeader = null,
)
assertFalse(result)
}
@Test
fun headUploadSuccessReturnsTrue() =
runTest {
val client = DesktopBlossomClient(mockOkHttp(200))
val result =
client.headUpload(
contentType = "image/png",
contentLength = 1024,
sha256 = "abc123",
serverBaseUrl = "https://blossom.example.com",
authHeader = null,
)
assertTrue(result)
}
@Test
fun headUploadFailureReturnsFalse() =
runTest {
val client = DesktopBlossomClient(mockOkHttp(403))
val result =
client.headUpload(
contentType = "image/png",
contentLength = 1024,
sha256 = "abc123",
serverBaseUrl = "https://blossom.example.com",
authHeader = null,
)
assertFalse(result)
}
@Test
fun uploadSendsAuthorizationHeader() =
runTest {