From e654d48cdf60e747253c17d119057d9e377c1937 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 16 Mar 2026 11:58:41 +0200 Subject: [PATCH] =?UTF-8?q?fix(media):=20code=20review=20=E2=80=94=20dead?= =?UTF-8?q?=20code=20removal,=20perf=20fixes,=20bug=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../desktop/model/MediaAspectRatioCache.kt | 36 -------- .../service/images/DesktopBase64Fetcher.kt | 3 +- .../service/images/DesktopBlurHashFetcher.kt | 17 +--- .../service/images/DesktopImageLoaderSetup.kt | 4 +- .../service/media/EncryptedMediaService.kt | 66 +-------------- .../desktop/service/media/VlcjPlayerPool.kt | 82 ++++++++++++++++--- .../service/upload/DesktopBlossomAuth.kt | 9 -- .../service/upload/DesktopBlossomClient.kt | 39 --------- .../desktop/ui/chats/ChatFileAttachment.kt | 15 ---- .../amethyst/desktop/ui/media/AudioPlayer.kt | 50 ++++------- .../desktop/ui/media/DesktopVideoPlayer.kt | 26 ++---- .../desktop/ui/media/SaveMediaAction.kt | 26 +++--- .../desktop/ui/media/VideoControls.kt | 2 +- .../desktop/ui/media/ZoomableImage.kt | 5 -- .../amethyst/desktop/ui/note/NoteCard.kt | 17 ++-- .../ui/settings/MediaServerSettings.kt | 27 ++++-- .../upload/DesktopBlossomClientTest.kt | 65 --------------- 17 files changed, 143 insertions(+), 346 deletions(-) delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/MediaAspectRatioCache.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/MediaAspectRatioCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/MediaAspectRatioCache.kt deleted file mode 100644 index ffe54e243..000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/MediaAspectRatioCache.kt +++ /dev/null @@ -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(1000) - - fun get(url: String): Float? = cache[url] - - fun put( - url: String, - ratio: Float, - ) { - cache.put(url, ratio) - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt index 248903ad3..6e534d068 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBase64Fetcher.kt @@ -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 } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt index 6a3081259..1e4728899 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopBlurHashFetcher.kt @@ -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 { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt index b3a32c416..ebaad3df6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt @@ -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() } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt index 8a68ccf3f..cd8a55436 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt @@ -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?, - 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. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt index ed13146c2..68c49dce0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt @@ -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() private val idlePlayers = ConcurrentLinkedQueue() - private const val MAX_POOL_SIZE = 3 + // Audio player pool (shared factory with --no-video) + private var audioFactory: MediaPlayerFactory? = null + private val allAudioPlayers = mutableListOf() + private val idleAudioPlayers = ConcurrentLinkedQueue() + 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) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt index 1ee16b2f6..b93214497 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt @@ -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" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt index 9634c342f..ce99b0732 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt @@ -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(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 } - } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt index 5837b9270..35db63f89 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt @@ -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(null) } - var decryptedBytes by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(false) } var error by remember { mutableStateOf(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) - } - } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt index fca9ff730..0b6bd0c4f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt @@ -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(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) -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt index 0aa103771..05bfedb62 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -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) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt index 15bb10afa..f762d3096 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt @@ -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 } } + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt index bc41ad01c..b986a8056 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt index c80811d33..8f5e0e832 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ZoomableImage.kt @@ -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() -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 52f93f674..0111a01b6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt index 8dc85c540..130d20b43 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt @@ -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 } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt index 00cefee5c..75b94e6a8 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClientTest.kt @@ -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 {