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:
-36
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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
|
||||
}
|
||||
|
||||
+2
-15
@@ -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> {
|
||||
|
||||
+2
-2
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+1
-65
@@ -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.
|
||||
|
||||
+69
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -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"
|
||||
|
||||
-39
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
-15
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-35
@@ -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)
|
||||
}
|
||||
|
||||
+9
-17
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-10
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
-5
@@ -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()
|
||||
}
|
||||
|
||||
+9
-8
@@ -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
|
||||
|
||||
+18
-9
@@ -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
|
||||
}
|
||||
|
||||
-65
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user