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 @Stable
class DesktopBase64Fetcher( class DesktopBase64Fetcher(
private val options: Options,
private val data: Uri, private val data: Uri,
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? = override suspend fun fetch(): FetchResult? =
@@ -64,7 +63,7 @@ class DesktopBase64Fetcher(
imageLoader: ImageLoader, imageLoader: ImageLoader,
): Fetcher? = ): Fetcher? =
if (data.scheme == "data") { if (data.scheme == "data") {
DesktopBase64Fetcher(options, data) DesktopBase64Fetcher(data)
} else { } else {
null null
} }
@@ -31,9 +31,6 @@ import coil3.key.Keyer
import coil3.request.Options import coil3.request.Options
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage 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( data class BlurhashWrapper(
val blurhash: String, val blurhash: String,
@@ -41,23 +38,13 @@ data class BlurhashWrapper(
@Stable @Stable
class DesktopBlurHashFetcher( class DesktopBlurHashFetcher(
private val options: Options,
private val data: BlurhashWrapper, private val data: BlurhashWrapper,
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
val hash = data.blurhash val hash = data.blurhash
val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
val bufferedImage = platformImage.toBufferedImage() val bufferedImage = platformImage.toBufferedImage()
val bitmap = bufferedImageToSkiaBitmap(bufferedImage)
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()
return ImageFetchResult( return ImageFetchResult(
image = bitmap.asImage(true), image = bitmap.asImage(true),
@@ -71,7 +58,7 @@ class DesktopBlurHashFetcher(
data: BlurhashWrapper, data: BlurhashWrapper,
options: Options, options: Options,
imageLoader: ImageLoader, imageLoader: ImageLoader,
): Fetcher = DesktopBlurHashFetcher(options, data) ): Fetcher = DesktopBlurHashFetcher(data)
} }
object BKeyer : Keyer<BlurhashWrapper> { object BKeyer : Keyer<BlurhashWrapper> {
@@ -54,11 +54,11 @@ object DesktopImageLoaderSetup {
private fun newMemoryCache(): MemoryCache { private fun newMemoryCache(): MemoryCache {
val maxMemory = Runtime.getRuntime().maxMemory() 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 return MemoryCache
.Builder() .Builder()
.maxSizeBytes(cacheSize) .maxSizeBytes(cacheSize)
.strongReferencesEnabled(true) .strongReferencesEnabled(false)
.build() .build()
} }
@@ -20,82 +20,18 @@
*/ */
package com.vitorpamplona.amethyst.desktop.service.media 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 com.vitorpamplona.quartz.utils.ciphers.AESGCM
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request 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). * Uses AESGCM cipher from quartz (commonMain).
*/ */
object EncryptedMediaService { object EncryptedMediaService {
private val httpClient = OkHttpClient() 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. * Download and decrypt an encrypted file from a URL.
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.desktop.service.media package com.vitorpamplona.amethyst.desktop.service.media
import uk.co.caprica.vlcj.factory.MediaPlayerFactory 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.EmbeddedMediaPlayer
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
@@ -40,12 +40,17 @@ object VlcjPlayerPool {
private val available = AtomicBoolean(false) private val available = AtomicBoolean(false)
private var factory: MediaPlayerFactory? = null private var factory: MediaPlayerFactory? = null
// Strong references to ALL created players (prevents GC crash) // Video player pool
private val allPlayers = mutableListOf<EmbeddedMediaPlayer>() private val allPlayers = mutableListOf<EmbeddedMediaPlayer>()
private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>() private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
private const val MAX_POOL_SIZE = 3 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. * 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. * Returns null if VLC is not available or pool is at capacity.
*/ */
fun acquire(): EmbeddedMediaPlayer? { fun acquire(): EmbeddedMediaPlayer? {
if (!available.get()) return null if (!available.get()) return null
val f = factory ?: return null val f = factory ?: return null
// Reuse idle player
idlePlayers.poll()?.let { return it }
// Create new if under limit
synchronized(allPlayers) { synchronized(allPlayers) {
idlePlayers.poll()?.let { return it }
if (allPlayers.size >= MAX_POOL_SIZE) return null if (allPlayers.size >= MAX_POOL_SIZE) return null
return try { return try {
val player = f.mediaPlayers().newEmbeddedMediaPlayer() 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) { fun release(player: EmbeddedMediaPlayer) {
try { try {
player.controls().stop() player.controls().stop()
// Remove any event listeners to prevent stale callbacks
player.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() {},
)
idlePlayers.offer(player) idlePlayers.offer(player)
} catch (_: Exception) { } catch (_: Exception) {
// Player may already be disposed // 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. * Shut down the entire pool. Call on app exit.
*/ */
@@ -131,12 +169,30 @@ object VlcjPlayerPool {
} }
allPlayers.clear() allPlayers.clear()
} }
synchronized(allAudioPlayers) {
idleAudioPlayers.clear()
for (player in allAudioPlayers) {
try {
player.controls().stop()
player.release()
} catch (_: Exception) {
// Ignore
}
}
allAudioPlayers.clear()
}
try { try {
factory?.release() factory?.release()
} catch (_: Exception) { } catch (_: Exception) {
// Ignore // Ignore
} }
try {
audioFactory?.release()
} catch (_: Exception) {
// Ignore
}
factory = null factory = null
audioFactory = null
available.set(false) available.set(false)
} }
} }
@@ -36,15 +36,6 @@ object DesktopBlossomAuth {
return encodeAuthHeader(event) 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 { fun encodeAuthHeader(event: BlossomAuthorizationEvent): String {
val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray()) val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray())
return "Nostr $b64" return "Nostr $b64"
@@ -59,8 +59,6 @@ class DesktopBlossomClient(
.Builder() .Builder()
.url(apiUrl) .url(apiUrl)
.put(requestBody) .put(requestBody)
.addHeader("Content-Length", file.length().toString())
.addHeader("Content-Type", contentType)
authHeader?.let { requestBuilder.addHeader("Authorization", it) } authHeader?.let { requestBuilder.addHeader("Authorization", it) }
@@ -73,41 +71,4 @@ class DesktopBlossomClient(
JsonMapper.fromJson<BlossomUploadResult>(it.body.string()) 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -71,7 +70,6 @@ fun ChatFileAttachment(
val isImage = mimeType?.startsWith("image/") == true val isImage = mimeType?.startsWith("image/") == true
var decryptedImage by remember { mutableStateOf<ImageBitmap?>(null) } var decryptedImage by remember { mutableStateOf<ImageBitmap?>(null) }
var decryptedBytes by remember { mutableStateOf<ByteArray?>(null) }
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
@@ -81,7 +79,6 @@ fun ChatFileAttachment(
isLoading = true isLoading = true
try { try {
val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce) val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce)
decryptedBytes = bytes
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val skImage = SkiaImage.makeFromEncoded(bytes) val skImage = SkiaImage.makeFromEncoded(bytes)
decryptedImage = skImage.toComposeImageBitmap() 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import kotlinx.coroutines.delay 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.MediaPlayer
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
/** /**
* Audio-only player using VLCJ with no video surface. * Audio-only player using VLCJ with pooled audio players.
* Creates its own MediaPlayerFactory with "--no-video" flag. * Uses VlcjPlayerPool's shared audio factory instead of creating one per instance.
*/ */
@Composable @Composable
fun AudioPlayer( fun AudioPlayer(
@@ -71,17 +71,13 @@ fun AudioPlayer(
var player by remember { mutableStateOf<MediaPlayer?>(null) } var player by remember { mutableStateOf<MediaPlayer?>(null) }
DisposableEffect(url) { DisposableEffect(url) {
val factory = val mp = VlcjPlayerPool.acquireAudioPlayer()
try { if (mp == null) {
MediaPlayerFactory("--no-video", "--no-xlib") vlcAvailable = false
} catch (_: Exception) { return@DisposableEffect onDispose {}
vlcAvailable = false }
return@DisposableEffect onDispose {}
}
val mp = factory.mediaPlayers().newMediaPlayer() val listener =
mp.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() { object : MediaPlayerEventAdapter() {
override fun playing(mediaPlayer: MediaPlayer) { override fun playing(mediaPlayer: MediaPlayer) {
isPlaying = true isPlaying = true
@@ -109,24 +105,15 @@ fun AudioPlayer(
position = 0f position = 0f
currentTime = 0L currentTime = 0L
} }
}, }
)
mp.events().addMediaPlayerEventListener(listener)
player = mp player = mp
onDispose { onDispose {
player = null player = null
try { mp.events().removeMediaPlayerEventListener(listener)
mp.controls().stop() VlcjPlayerPool.releaseAudioPlayer(mp)
mp.release()
} catch (_: Exception) {
// Ignore
}
try {
factory.release()
} catch (_: Exception) {
// Ignore
}
} }
} }
@@ -192,7 +179,7 @@ fun AudioPlayer(
} }
Text( Text(
text = formatAudioTime(currentTime), text = formatTime(currentTime),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
) )
@@ -203,15 +190,8 @@ fun AudioPlayer(
) )
Text( Text(
text = formatAudioTime(duration), text = formatTime(duration),
style = MaterialTheme.typography.labelSmall, 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 java.nio.ByteBuffer
import org.jetbrains.skia.Image as SkiaImage 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 @Composable
fun DesktopVideoPlayer( fun DesktopVideoPlayer(
url: String, url: String,
@@ -93,10 +86,9 @@ fun DesktopVideoPlayer(
return@DisposableEffect onDispose {} return@DisposableEffect onDispose {}
} }
// Skia bitmap for DirectRendering // Skia bitmap for DirectRendering — pre-allocated to avoid per-frame GC
var skBitmap: Bitmap? = null var skBitmap: Bitmap? = null
var videoWidth = 0 var pixelBytes: ByteArray? = null
var videoHeight = 0
val bufferFormatCallback = val bufferFormatCallback =
object : BufferFormatCallback { object : BufferFormatCallback {
@@ -104,17 +96,15 @@ fun DesktopVideoPlayer(
sourceWidth: Int, sourceWidth: Int,
sourceHeight: Int, sourceHeight: Int,
): BufferFormat { ): BufferFormat {
videoWidth = sourceWidth
videoHeight = sourceHeight
if (sourceHeight > 0) { if (sourceHeight > 0) {
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat() aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat()
} }
// Allocate Skia bitmap
val bmp = Bitmap() val bmp = Bitmap()
bmp.allocPixels( bmp.allocPixels(
ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL), ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL),
) )
skBitmap = bmp skBitmap = bmp
pixelBytes = ByteArray(sourceWidth * sourceHeight * 4)
return RV32BufferFormat(sourceWidth, sourceHeight) return RV32BufferFormat(sourceWidth, sourceHeight)
} }
@@ -126,9 +116,9 @@ fun DesktopVideoPlayer(
val renderCallback = val renderCallback =
RenderCallback { _, nativeBuffers, _ -> RenderCallback { _, nativeBuffers, _ ->
val bmp = skBitmap ?: return@RenderCallback val bmp = skBitmap ?: return@RenderCallback
val bytes = pixelBytes ?: return@RenderCallback
val buffer = nativeBuffers[0] val buffer = nativeBuffers[0]
buffer.rewind() buffer.rewind()
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes) buffer.get(bytes)
bmp.installPixels(bytes) bmp.installPixels(bytes)
frame = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() frame = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
@@ -142,7 +132,7 @@ fun DesktopVideoPlayer(
acquired.videoSurface().set(surface) acquired.videoSurface().set(surface)
acquired.events().addMediaPlayerEventListener( val listener =
object : MediaPlayerEventAdapter() { object : MediaPlayerEventAdapter() {
override fun playing(mediaPlayer: MediaPlayer) { override fun playing(mediaPlayer: MediaPlayer) {
isPlaying = true isPlaying = true
@@ -170,8 +160,9 @@ fun DesktopVideoPlayer(
position = 0f position = 0f
currentTime = 0L currentTime = 0L
} }
}, }
)
acquired.events().addMediaPlayerEventListener(listener)
player = acquired player = acquired
@@ -183,6 +174,7 @@ fun DesktopVideoPlayer(
onDispose { onDispose {
player = null player = null
acquired.events().removeMediaPlayerEventListener(listener)
VlcjPlayerPool.release(acquired) VlcjPlayerPool.release(acquired)
} }
} }
@@ -38,19 +38,24 @@ object SaveMediaAction {
suspend fun saveMedia( suspend fun saveMedia(
url: String, url: String,
suggestedFilename: String? = null, suggestedFilename: String? = null,
): File? = ): File? {
withContext(Dispatchers.IO) { val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
val dialog = // FileDialog must be shown on EDT
FileDialog(null as Frame?, "Save Media", FileDialog.SAVE).apply { val file =
file = filename withContext(Dispatchers.Main) {
} val dialog =
dialog.isVisible = true FileDialog(null as Frame?, "Save Media", FileDialog.SAVE).apply {
this.file = filename
}
dialog.isVisible = true
val dir = dialog.directory ?: return@withContext null val dir = dialog.directory ?: return@withContext null
val file = File(dir, dialog.file ?: return@withContext null) File(dir, dialog.file ?: return@withContext null)
} ?: return null
// Download on IO
return withContext(Dispatchers.IO) {
try { try {
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
val response = httpClient.newCall(request).execute() val response = httpClient.newCall(request).execute()
@@ -67,4 +72,5 @@ object SaveMediaAction {
null null
} }
} }
}
} }
@@ -169,7 +169,7 @@ fun VideoControls(
} }
} }
private fun formatTime(millis: Long): String { internal fun formatTime(millis: Long): String {
val totalSeconds = millis / 1000 val totalSeconds = millis / 1000
val minutes = totalSeconds / 60 val minutes = totalSeconds / 60
val seconds = totalSeconds % 60 val seconds = totalSeconds % 60
@@ -42,7 +42,6 @@ import coil3.compose.AsyncImage
fun ZoomableImage( fun ZoomableImage(
url: String, url: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onDoubleClick: (() -> Unit)? = null,
) { ) {
var scale by remember { mutableFloatStateOf(1f) } var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) } 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.AudioPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer 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. * Data class for displaying a note card.
*/ */
@@ -86,27 +88,26 @@ fun NoteCard(
remember(urls) { remember(urls) {
urls.withScheme.filter { RichTextParser.isImageUrl(it) } urls.withScheme.filter { RichTextParser.isImageUrl(it) }
} }
val allMediaUrls = val videoAndAudioUrls =
remember(urls) { remember(urls) {
urls.withScheme.filter { RichTextParser.isVideoUrl(it) } urls.withScheme.filter { RichTextParser.isVideoUrl(it) }
} }
val audioExtensions = setOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a")
val audioUrls = val audioUrls =
remember(allMediaUrls) { remember(videoAndAudioUrls) {
allMediaUrls.filter { url -> videoAndAudioUrls.filter { url ->
val ext = val ext =
url url
.substringAfterLast('.', "") .substringAfterLast('.', "")
.substringBefore('?') .substringBefore('?')
.lowercase() .lowercase()
ext in audioExtensions ext in AUDIO_EXTENSIONS
} }
} }
val videoUrls = val videoUrls =
remember(allMediaUrls, audioUrls) { remember(videoAndAudioUrls, audioUrls) {
allMediaUrls - audioUrls.toSet() videoAndAudioUrls - audioUrls.toSet()
} }
val mediaUrls = remember(imageUrls, allMediaUrls) { (imageUrls + allMediaUrls).toSet() } val mediaUrls = remember(imageUrls, videoAndAudioUrls) { (imageUrls + videoAndAudioUrls).toSet() }
val strippedContent = val strippedContent =
remember(note.content, mediaUrls) { remember(note.content, mediaUrls) {
var text = note.content var text = note.content
@@ -60,6 +60,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -74,12 +75,16 @@ fun MediaServerSettings(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var isChecking by remember { mutableStateOf(false) } var isChecking by remember { mutableStateOf(false) }
// Check health on first load // Check health on first load (parallel)
LaunchedEffect(servers.toList()) { LaunchedEffect(servers.toList()) {
for (server in servers) { coroutineScope {
if (server !in serverStatuses) { for (server in servers) {
val status = ServerHealthCheck.check(server) if (server !in serverStatuses) {
serverStatuses[server] = status launch {
val status = ServerHealthCheck.check(server)
serverStatuses[server] = status
}
}
} }
} }
} }
@@ -172,10 +177,14 @@ fun MediaServerSettings(
onClick = { onClick = {
scope.launch { scope.launch {
isChecking = true isChecking = true
for (server in servers) { coroutineScope {
serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN for (server in servers) {
val status = ServerHealthCheck.check(server) launch {
serverStatuses[server] = status serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN
val status = ServerHealthCheck.check(server)
serverStatuses[server] = status
}
}
} }
isChecking = false isChecking = false
} }
@@ -35,7 +35,6 @@ import java.io.File
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
class DesktopBlossomClientTest { 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 @Test
fun uploadSendsAuthorizationHeader() = fun uploadSendsAuthorizationHeader() =
runTest { runTest {