diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 3f19aeed6..0faa64174 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -31,6 +31,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button @@ -146,7 +148,13 @@ sealed class DesktopScreen { fun main() { DesktopImageLoaderSetup.setup() - Runtime.getRuntime().addShutdownHook(Thread { VlcjPlayerPool.shutdown() }) + Runtime.getRuntime().addShutdownHook( + Thread { + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .shutdown() + VlcjPlayerPool.shutdown() + }, + ) // Pre-init VLC on background thread so first play is fast Thread { VlcjPlayerPool.init() }.start() application { @@ -687,61 +695,71 @@ fun MainContent( val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current Box(Modifier.fillMaxSize()) { - Row(Modifier.fillMaxSize()) { - when (layoutMode) { - LayoutMode.SINGLE_PANE -> { - SinglePaneLayout( - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - modifier = Modifier.weight(1f), - ) - } - - LayoutMode.DECK -> { - if (!isImmersive) { - DeckSidebar( - onAddColumn = onShowAddColumnDialog, - onOpenSettings = { - if (deckState.hasColumnOfType(DeckColumnType.Settings)) { - deckState.focusExistingColumn(DeckColumnType.Settings) - } else { - deckState.addColumn(DeckColumnType.Settings) - } - }, + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxSize().weight(1f)) { + when (layoutMode) { + LayoutMode.SINGLE_PANE -> { + SinglePaneLayout( + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, + modifier = Modifier.weight(1f), ) - - VerticalDivider() } - DeckLayout( - deckState = deckState, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - modifier = Modifier.weight(1f), - ) + LayoutMode.DECK -> { + if (!isImmersive) { + DeckSidebar( + onAddColumn = onShowAddColumnDialog, + onOpenSettings = { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + }, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + ) + + VerticalDivider() + } + + DeckLayout( + deckState = deckState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + modifier = Modifier.weight(1f), + ) + } } - } - } + } // end Row + + // Persistent media control bar + com.vitorpamplona.amethyst.desktop.ui.media + .NowPlayingBar() + } // end Column + + // Global fullscreen video overlay + com.vitorpamplona.amethyst.desktop.ui.media + .GlobalFullscreenOverlay() // Snackbar for zap feedback SnackbarHost( @@ -804,7 +822,9 @@ fun RelaySettingsScreen( accountManager.loadNwcConnection() } - Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + ) { Text( "Settings", style = MaterialTheme.typography.headlineMedium, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt new file mode 100644 index 000000000..f656bcd72 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt @@ -0,0 +1,494 @@ +/* + * 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.service.media + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import com.vitorpamplona.amethyst.desktop.ui.media.MediaType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter +import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat +import java.nio.ByteBuffer +import org.jetbrains.skia.Image as SkiaImage + +data class MediaPlaybackState( + val url: String? = null, + val type: MediaType = MediaType.VIDEO, + val isPlaying: Boolean = false, + val isBuffering: Boolean = false, + val position: Float = 0f, + val duration: Long = 0L, + val currentTime: Long = 0L, + val aspectRatio: Float = 16f / 9f, + val volume: Int = 100, + val isMuted: Boolean = false, +) + +object GlobalMediaPlayer { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + // Video state + private val _videoFrame = MutableStateFlow(null) + val videoFrame: StateFlow = _videoFrame.asStateFlow() + + private val _videoState = MutableStateFlow(MediaPlaybackState()) + val videoState: StateFlow = _videoState.asStateFlow() + + // Audio state + private val _audioState = MutableStateFlow(MediaPlaybackState(type = MediaType.AUDIO)) + val audioState: StateFlow = _audioState.asStateFlow() + + // Fullscreen + private val _isFullscreen = MutableStateFlow(false) + val isFullscreen: StateFlow = _isFullscreen.asStateFlow() + + // VLC players — kept alive between plays + private var videoPlayer: EmbeddedMediaPlayer? = null + private var audioPlayer: MediaPlayer? = null + + // Skia bitmap for video rendering + private var skBitmap: Bitmap? = null + private var pixelBytes: ByteArray? = null + + // Position polling job + private var videoPollingJob: Job? = null + private var audioPollingJob: Job? = null + + fun playVideo( + url: String, + seekPosition: Float = 0f, + ) { + // If already playing this URL, just seek + val current = _videoState.value + if (current.url == url && videoPlayer != null) { + if (seekPosition > 0f) { + videoPlayer?.controls()?.setPosition(seekPosition) + } + if (!current.isPlaying) { + videoPlayer?.controls()?.play() + } + return + } + + // Stop current video if different URL + if (current.url != null && current.url != url) { + videoPlayer?.controls()?.stop() + } + + _videoState.value = + MediaPlaybackState( + url = url, + type = MediaType.VIDEO, + isBuffering = true, + ) + + scope.launch(Dispatchers.IO) { + if (!VlcjPlayerPool.init()) { + _videoState.value = _videoState.value.copy(isBuffering = false) + return@launch + } + + val player = + videoPlayer ?: VlcjPlayerPool.acquire() ?: run { + _videoState.value = _videoState.value.copy(isBuffering = false) + return@launch + } + + // Only set up surface on first acquisition + if (videoPlayer == null) { + setupVideoSurface(player) + setupVideoEventListener(player) + videoPlayer = player + } + + var didSeek = seekPosition <= 0f + + // Temporary listener for initial seek + if (!didSeek) { + val seekListener = + object : MediaPlayerEventAdapter() { + override fun playing(mediaPlayer: MediaPlayer) { + if (!didSeek) { + didSeek = true + mediaPlayer.controls().setPosition(seekPosition) + mediaPlayer.events().removeMediaPlayerEventListener(this) + } + } + } + player.events().addMediaPlayerEventListener(seekListener) + } + + player.media().play(url) + startVideoPolling() + } + } + + fun playAudio(url: String) { + val current = _audioState.value + if (current.url == url && audioPlayer != null) { + if (!current.isPlaying) { + audioPlayer?.controls()?.play() + } + return + } + + if (current.url != null && current.url != url) { + audioPlayer?.controls()?.stop() + } + + _audioState.value = + MediaPlaybackState( + url = url, + type = MediaType.AUDIO, + isBuffering = true, + ) + + scope.launch(Dispatchers.IO) { + val player = + audioPlayer ?: VlcjPlayerPool.acquireAudioPlayer() ?: run { + _audioState.value = _audioState.value.copy(isBuffering = false) + return@launch + } + + if (audioPlayer == null) { + setupAudioEventListener(player) + audioPlayer = player + } + + player.media().play(url) + startAudioPolling() + } + } + + fun toggleVideoPlayPause() { + val player = videoPlayer ?: return + val state = _videoState.value + if (state.url == null) return + + if (state.isPlaying) { + player.controls().pause() + } else { + if (state.position <= 0f && !player.status().isPlaying) { + state.url.let { player.media().play(it) } + } else { + player.controls().play() + } + } + } + + fun toggleAudioPlayPause() { + val player = audioPlayer ?: return + val state = _audioState.value + if (state.url == null) return + + if (state.isPlaying) { + player.controls().pause() + } else { + if (state.position <= 0f && !player.status().isPlaying) { + state.url.let { player.media().play(it) } + } else { + player.controls().play() + } + } + } + + fun seekVideo(position: Float) { + videoPlayer?.controls()?.setPosition(position) + } + + fun seekAudio(position: Float) { + audioPlayer?.controls()?.setPosition(position) + } + + fun setVideoVolume(volume: Int) { + videoPlayer?.audio()?.setVolume(volume) + _videoState.value = _videoState.value.copy(volume = volume) + } + + fun setAudioVolume(volume: Int) { + audioPlayer?.audio()?.setVolume(volume) + _audioState.value = _audioState.value.copy(volume = volume) + } + + fun toggleVideoMute() { + val muted = !_videoState.value.isMuted + videoPlayer?.audio()?.isMute = muted + _videoState.value = _videoState.value.copy(isMuted = muted) + } + + fun toggleAudioMute() { + val muted = !_audioState.value.isMuted + audioPlayer?.audio()?.isMute = muted + _audioState.value = _audioState.value.copy(isMuted = muted) + } + + fun stopVideo() { + videoPollingJob?.cancel() + videoPollingJob = null + videoPlayer?.controls()?.stop() + _videoState.value = MediaPlaybackState() + _videoFrame.value = null + _isFullscreen.value = false + } + + fun stopAudio() { + audioPollingJob?.cancel() + audioPollingJob = null + audioPlayer?.controls()?.stop() + _audioState.value = MediaPlaybackState(type = MediaType.AUDIO) + } + + fun toggleFullscreen() { + _isFullscreen.value = !_isFullscreen.value + } + + fun exitFullscreen() { + _isFullscreen.value = false + } + + fun shutdown() { + videoPollingJob?.cancel() + audioPollingJob?.cancel() + + videoPlayer?.let { p -> + try { + p.controls().stop() + } catch (_: Exception) { + } + VlcjPlayerPool.release(p) + } + videoPlayer = null + + audioPlayer?.let { p -> + try { + p.controls().stop() + } catch (_: Exception) { + } + VlcjPlayerPool.releaseAudioPlayer(p) + } + audioPlayer = null + + _videoState.value = MediaPlaybackState() + _audioState.value = MediaPlaybackState(type = MediaType.AUDIO) + _videoFrame.value = null + _isFullscreen.value = false + + scope.cancel() + } + + private fun setupVideoSurface(player: EmbeddedMediaPlayer) { + val bufferFormatCallback = + object : BufferFormatCallback { + override fun getBufferFormat( + sourceWidth: Int, + sourceHeight: Int, + ): BufferFormat { + if (sourceHeight > 0) { + _videoState.value = + _videoState.value.copy( + aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat(), + ) + } + val bmp = Bitmap() + bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL)) + skBitmap = bmp + pixelBytes = ByteArray(sourceWidth * sourceHeight * 4) + return RV32BufferFormat(sourceWidth, sourceHeight) + } + + override fun allocatedBuffers(buffers: Array) {} + } + + val renderCallback = + RenderCallback { _, nativeBuffers, _ -> + val bmp = skBitmap ?: return@RenderCallback + val bytes = pixelBytes ?: return@RenderCallback + val buffer = nativeBuffers[0] + buffer.rewind() + buffer.get(bytes) + bmp.installPixels(bytes) + _videoFrame.value = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() + } + + val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback) + player.videoSurface().set(surface) + } + + private fun setupVideoEventListener(player: EmbeddedMediaPlayer) { + player.events().addMediaPlayerEventListener( + object : MediaPlayerEventAdapter() { + override fun playing(mediaPlayer: MediaPlayer) { + _videoState.value = + _videoState.value.copy( + isPlaying = true, + isBuffering = false, + duration = mediaPlayer.status().length(), + ) + } + + override fun paused(mediaPlayer: MediaPlayer) { + _videoState.value = _videoState.value.copy(isPlaying = false) + } + + override fun stopped(mediaPlayer: MediaPlayer) { + _videoState.value = _videoState.value.copy(isPlaying = false, isBuffering = false) + } + + override fun buffering( + mediaPlayer: MediaPlayer, + newCache: Float, + ) { + _videoState.value = _videoState.value.copy(isBuffering = newCache < 100f) + } + + override fun positionChanged( + mediaPlayer: MediaPlayer, + newPosition: Float, + ) { + _videoState.value = + _videoState.value.copy( + position = newPosition, + currentTime = (newPosition * _videoState.value.duration).toLong(), + ) + } + + override fun finished(mediaPlayer: MediaPlayer) { + _videoState.value = + _videoState.value.copy( + isPlaying = false, + isBuffering = false, + position = 0f, + currentTime = 0L, + ) + } + + override fun error(mediaPlayer: MediaPlayer) { + _videoState.value = _videoState.value.copy(isBuffering = false) + println("VLC: playback error for ${_videoState.value.url}") + } + }, + ) + } + + private fun setupAudioEventListener(player: MediaPlayer) { + player.events().addMediaPlayerEventListener( + object : MediaPlayerEventAdapter() { + override fun playing(mediaPlayer: MediaPlayer) { + _audioState.value = + _audioState.value.copy( + isPlaying = true, + isBuffering = false, + duration = mediaPlayer.status().length(), + ) + } + + override fun paused(mediaPlayer: MediaPlayer) { + _audioState.value = _audioState.value.copy(isPlaying = false) + } + + override fun stopped(mediaPlayer: MediaPlayer) { + _audioState.value = _audioState.value.copy(isPlaying = false, isBuffering = false) + } + + override fun positionChanged( + mediaPlayer: MediaPlayer, + newPosition: Float, + ) { + _audioState.value = + _audioState.value.copy( + position = newPosition, + currentTime = (newPosition * _audioState.value.duration).toLong(), + ) + } + + override fun finished(mediaPlayer: MediaPlayer) { + _audioState.value = + _audioState.value.copy( + isPlaying = false, + position = 0f, + currentTime = 0L, + ) + } + }, + ) + } + + private fun startVideoPolling() { + videoPollingJob?.cancel() + videoPollingJob = + scope.launch { + while (true) { + delay(500) + val player = videoPlayer ?: break + val state = _videoState.value + if (state.isPlaying) { + try { + _videoState.value = + state.copy( + position = player.status().position(), + currentTime = player.status().time(), + ) + } catch (_: Exception) { + } + } + } + } + } + + private fun startAudioPolling() { + audioPollingJob?.cancel() + audioPollingJob = + scope.launch { + while (true) { + delay(500) + val player = audioPlayer ?: break + val state = _audioState.value + if (state.isPlaying) { + try { + _audioState.value = + state.copy( + position = player.status().position(), + currentTime = player.status().time(), + ) + } catch (_: Exception) { + } + } + } + } + } +} 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 37225791c..18e08c306 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 @@ -48,7 +48,7 @@ object VlcjPlayerPool { // Video player pool (for actual playback) private val allPlayers = mutableListOf() private val idlePlayers = ConcurrentLinkedQueue() - private const val MAX_POOL_SIZE = 4 + private const val MAX_POOL_SIZE = 1 // Thumbnail player pool (separate so thumbnails don't compete with playback) private val allThumbPlayers = mutableListOf() @@ -59,7 +59,7 @@ object VlcjPlayerPool { private var audioFactory: MediaPlayerFactory? = null private val allAudioPlayers = mutableListOf() private val idleAudioPlayers = ConcurrentLinkedQueue() - private const val MAX_AUDIO_POOL_SIZE = 5 + private const val MAX_AUDIO_POOL_SIZE = 1 /** * Initialize the pool. Thread-safe — only runs once. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 378fcbf82..b8fee1344 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.ui -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow @@ -122,15 +122,11 @@ fun FeedNoteCard( ) { val zapAmountSats = zapReceipts.sumOf { it.amountSats } - Column( - modifier = - Modifier.clickable { - onNavigateToThread(event.id) - }, - ) { + Column { NoteCard( note = event.toNoteDisplayData(localCache), localCache = localCache, + onClick = { onNavigateToThread(event.id) }, onAuthorClick = onNavigateToProfile, onMentionClick = onNavigateToProfile, onImageClick = onImageClick, @@ -507,149 +503,156 @@ fun FeedScreen( } @OptIn(ExperimentalLayoutApi::class) - Column(modifier = Modifier.fillMaxSize()) { - // Header with compose button — wraps on narrow columns - FlowRow( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column { - FlowRow( - verticalArrangement = Arrangement.Center, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + // Header with compose button — wraps on narrow columns + FlowRow( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column { + FlowRow( + verticalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) - // Feed mode selector - if (account != null) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - FilterChip( - selected = feedMode == FeedMode.GLOBAL, - onClick = { - feedMode = FeedMode.GLOBAL - DesktopPreferences.feedMode = FeedMode.GLOBAL - }, - label = { Text("Global") }, + // Feed mode selector + if (account != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { + feedMode = FeedMode.GLOBAL + DesktopPreferences.feedMode = FeedMode.GLOBAL + }, + label = { Text("Global") }, + ) + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { + feedMode = FeedMode.FOLLOWING + DesktopPreferences.feedMode = FeedMode.FOLLOWING + }, + label = { Text("Following") }, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${connectedRelays.size} relays connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (feedMode == FeedMode.FOLLOWING) { + Text( + " • ${followedUsers.size} followed", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - FilterChip( - selected = feedMode == FeedMode.FOLLOWING, - onClick = { - feedMode = FeedMode.FOLLOWING - DesktopPreferences.feedMode = FeedMode.FOLLOWING - }, - label = { Text("Following") }, + } + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { relayManager.connect() }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), ) } } } - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "${connectedRelays.size} relays connected", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (feedMode == FeedMode.FOLLOWING) { - Text( - " • ${followedUsers.size} followed", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + // New Post button (primary action) + Button( + onClick = onCompose, + enabled = account != null && !account.isReadOnly, + ) { + Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - IconButton( - onClick = { relayManager.connect() }, - modifier = Modifier.size(24.dp), - ) { - Icon( - Icons.Default.Refresh, - contentDescription = "Refresh", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), + Text("New Post") + } + } + + Spacer(Modifier.height(8.dp)) + + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { + LoadingState("Loading followed users...") + } else if (events.isEmpty() && !initialLoadComplete) { + LoadingState("Loading notes...") + } else if (events.isEmpty() && initialLoadComplete) { + EmptyState( + title = + if (feedMode == FeedMode.FOLLOWING) { + "No notes from followed users" + } else { + "No notes found" + }, + description = + if (feedMode == FeedMode.FOLLOWING) { + "Notes from people you follow will appear here" + } else { + "Notes from the network will appear here" + }, + onRefresh = { relayManager.connect() }, + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Use distinctBy to prevent duplicate key crashes from events with same ID + items(events.distinctBy { it.id }, key = { it.id }) { event -> + FeedNoteCard( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = { replyToEvent = event }, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + zapReceipts = zapsByEvent[event.id] ?: emptyList(), + reactionCount = reactionsByEvent[event.id] ?: 0, + replyCount = repliesByEvent[event.id] ?: 0, + repostCount = repostsByEvent[event.id] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(event.id), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, ) } } } - - // New Post button (primary action) - Button( - onClick = onCompose, - enabled = account != null && !account.isReadOnly, - ) { - Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("New Post") - } - } - - Spacer(Modifier.height(8.dp)) - - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { - LoadingState("Loading followed users...") - } else if (events.isEmpty() && !initialLoadComplete) { - LoadingState("Loading notes...") - } else if (events.isEmpty() && initialLoadComplete) { - EmptyState( - title = - if (feedMode == FeedMode.FOLLOWING) { - "No notes from followed users" - } else { - "No notes found" - }, - description = - if (feedMode == FeedMode.FOLLOWING) { - "Notes from people you follow will appear here" - } else { - "Notes from the network will appear here" - }, - onRefresh = { relayManager.connect() }, - ) - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Use distinctBy to prevent duplicate key crashes from events with same ID - items(events.distinctBy { it.id }, key = { it.id }) { event -> - FeedNoteCard( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = { replyToEvent = event }, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, - onMediaClick = { urls, index, seekPos -> lightboxState = LightboxState(urls, index, seekPos, fullscreen = true) }, - zapReceipts = zapsByEvent[event.id] ?: emptyList(), - reactionCount = reactionsByEvent[event.id] ?: 0, - replyCount = repliesByEvent[event.id] ?: 0, - repostCount = repostsByEvent[event.id] ?: 0, - bookmarkList = bookmarkList, - isBookmarked = bookmarkedEventIds.contains(event.id), - onBookmarkChanged = { newList -> - bookmarkList = newList - val pubIds = - newList - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds - }, - ) - } - } - } + } // end Column // Reply dialog if (replyToEvent != null && account != null) { @@ -671,5 +674,5 @@ fun FeedScreen( onDismiss = { lightboxState = null }, ) } - } + } // end Box } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index d9738a3ad..d4368f984 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -69,6 +70,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscriptio import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys import com.vitorpamplona.quartz.nip01Core.core.Event @@ -137,6 +139,9 @@ fun ThreadScreen( var bookmarkList by remember { mutableStateOf(null) } var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } + // Lightbox state + var lightboxState by remember { mutableStateOf(null) } + // Load metadata for thread authors + mentioned users via coordinator LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) { if (subscriptionsCoordinator != null) { @@ -343,177 +348,200 @@ fun ThreadScreen( return level } - Column(modifier = Modifier.fillMaxSize()) { - // Header with back button - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - modifier = Modifier.size(24.dp), + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + // Header with back button + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(24.dp), + ) + } + Spacer(Modifier.width(8.dp)) + Text( + "Thread", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, ) } - Spacer(Modifier.width(8.dp)) - Text( - "Thread", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - } - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else if (rootNote == null && !rootNoteEoseReceived) { - LoadingState("Loading thread...") - } else if (rootNote == null && rootNoteEoseReceived) { - EmptyState( - title = "Note not found", - description = "This note may have been deleted or is not available from connected relays", - onRefresh = onBack, - refreshLabel = "Go back", - ) - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - // Root note (no reply level indicator) - item(key = noteId) { - Column( - modifier = - Modifier.clickable { - // Already viewing this thread, no-op - }, - ) { - NoteCard( - note = rootNote!!.toNoteDisplayData(localCache), - localCache = localCache, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - ) - if (account != null) { - val rootZaps = zapsByEvent[noteId] ?: emptyList() - NoteActionsRow( - event = rootNote!!, - relayManager = relayManager, + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else if (rootNote == null && !rootNoteEoseReceived) { + LoadingState("Loading thread...") + } else if (rootNote == null && rootNoteEoseReceived) { + EmptyState( + title = "Note not found", + description = "This note may have been deleted or is not available from connected relays", + onRefresh = onBack, + refreshLabel = "Go back", + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + // Root note (no reply level indicator) + item(key = noteId) { + Column { + NoteCard( + note = rootNote!!.toNoteDisplayData(localCache), localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReplyClick = { onReply(rootNote!!) }, - onZapFeedback = onZapFeedback, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = rootZaps.size, - zapAmountSats = rootZaps.sumOf { it.amountSats }, - zapReceipts = rootZaps, - reactionCount = reactionsByEvent[noteId] ?: 0, - replyCount = repliesByEvent[noteId] ?: 0, - repostCount = repostsByEvent[noteId] ?: 0, - bookmarkList = bookmarkList, - isBookmarked = bookmarkedEventIds.contains(noteId), - onBookmarkChanged = { newList -> - bookmarkList = newList - val pubIds = - newList - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() }, ) + if (account != null) { + val rootZaps = zapsByEvent[noteId] ?: emptyList() + NoteActionsRow( + event = rootNote!!, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = { onReply(rootNote!!) }, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = rootZaps.size, + zapAmountSats = rootZaps.sumOf { it.amountSats }, + zapReceipts = rootZaps, + reactionCount = reactionsByEvent[noteId] ?: 0, + replyCount = repliesByEvent[noteId] ?: 0, + repostCount = repostsByEvent[noteId] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(noteId), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, + ) + } } + HorizontalDivider(thickness = 1.dp) } - HorizontalDivider(thickness = 1.dp) - } - // Reply notes with level indicators - items(replyEvents.distinctBy { it.id }, key = { it.id }) { event -> - val level = calculateLevel(event) + // Reply notes with level indicators + items(replyEvents.distinctBy { it.id }, key = { it.id }) { event -> + val level = calculateLevel(event) - Column( - modifier = - Modifier - .drawReplyLevel( - level = level, - color = MaterialTheme.colorScheme.outlineVariant, - selected = - if (event.id == noteId) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outlineVariant - }, - ).clickable { - onNavigateToThread(event.id) - }, - ) { - NoteCard( - note = event.toNoteDisplayData(localCache), - localCache = localCache, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - ) - if (account != null) { - val eventZaps = zapsByEvent[event.id] ?: emptyList() - NoteActionsRow( - event = event, - relayManager = relayManager, + Column( + modifier = + Modifier + .drawReplyLevel( + level = level, + color = MaterialTheme.colorScheme.outlineVariant, + selected = + if (event.id == noteId) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, + ).clickable { + onNavigateToThread(event.id) + }, + ) { + NoteCard( + note = event.toNoteDisplayData(localCache), localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReplyClick = { onReply(event) }, - onZapFeedback = onZapFeedback, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = eventZaps.size, - zapAmountSats = eventZaps.sumOf { it.amountSats }, - zapReceipts = eventZaps, - reactionCount = reactionsByEvent[event.id] ?: 0, - replyCount = repliesByEvent[event.id] ?: 0, - repostCount = repostsByEvent[event.id] ?: 0, - bookmarkList = bookmarkList, - isBookmarked = bookmarkedEventIds.contains(event.id), - onBookmarkChanged = { newList -> - bookmarkList = newList - val pubIds = - newList - .publicBookmarks() - .filterIsInstance() - .map { it.eventId } - .toSet() - bookmarkedEventIds = pubIds + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() }, ) + if (account != null) { + val eventZaps = zapsByEvent[event.id] ?: emptyList() + NoteActionsRow( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = { onReply(event) }, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = eventZaps.size, + zapAmountSats = eventZaps.sumOf { it.amountSats }, + zapReceipts = eventZaps, + reactionCount = reactionsByEvent[event.id] ?: 0, + replyCount = repliesByEvent[event.id] ?: 0, + repostCount = repostsByEvent[event.id] ?: 0, + bookmarkList = bookmarkList, + isBookmarked = bookmarkedEventIds.contains(event.id), + onBookmarkChanged = { newList -> + bookmarkList = newList + val pubIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + bookmarkedEventIds = pubIds + }, + ) + } } + HorizontalDivider(thickness = 1.dp) } - HorizontalDivider(thickness = 1.dp) - } - // Empty state for no replies - if (replyEvents.isEmpty() && repliesEoseReceived) { - item { - Spacer(Modifier.height(32.dp)) - Text( - "No replies yet", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), - ) - } - } else if (replyEvents.isEmpty() && !repliesEoseReceived) { - item { - Spacer(Modifier.height(32.dp)) - Text( - "Loading replies...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), - ) + // Empty state for no replies + if (replyEvents.isEmpty() && repliesEoseReceived) { + item { + Spacer(Modifier.height(32.dp)) + Text( + "No replies yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } else if (replyEvents.isEmpty() && !repliesEoseReceived) { + item { + Spacer(Modifier.height(32.dp)) + Text( + "Loading replies...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } } } } + } // end Column + + // Lightbox overlay + val lb = lightboxState + if (lb != null) { + LightboxOverlay( + urls = lb.urls, + initialIndex = lb.index, + initialSeekPosition = lb.seekPosition, + initialFullscreen = lb.fullscreen, + onDismiss = { lightboxState = null }, + ) } - } + } // end Box } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 20cdbae08..262dde6d8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -714,7 +714,10 @@ fun UserProfileScreen( lightboxState = LightboxState(urls, index) }, onMediaClick = { urls, index, seekPos -> - lightboxState = LightboxState(urls, index, seekPos, fullscreen = true) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ActiveMediaManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ActiveMediaManager.kt deleted file mode 100644 index ac4dbab25..000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/ActiveMediaManager.kt +++ /dev/null @@ -1,42 +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.ui.media - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -/** - * Ensures only one media player is active at a time. - * Each player registers with a unique ID. When a new player activates, - * the previous one observes the change and pauses itself. - */ -object ActiveMediaManager { - private val _activeId = MutableStateFlow(null) - val activeId: StateFlow = _activeId - - fun activate(id: String) { - _activeId.value = id - } - - fun deactivate(id: String) { - _activeId.compareAndSet(id, null) - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt new file mode 100644 index 000000000..1872b7b04 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt @@ -0,0 +1,176 @@ +/* + * 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.ui.media + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Codec +import org.jetbrains.skia.Data +import java.util.concurrent.TimeUnit + +private const val MAX_BITMAP_MEMORY = 64L * 1024 * 1024 // 64MB per GIF +private const val MIN_FRAME_DURATION_MS = 20 + +private val gifHttpClient: OkHttpClient by lazy { + OkHttpClient + .Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() +} + +fun isAnimatedGifUrl(url: String): Boolean { + val lower = url.lowercase() + return lower.endsWith(".gif") || + lower.contains(".gif?") || + lower.contains(".gif#") +} + +private class GifFrames( + val frames: List, + val durations: List, +) + +@Composable +fun AnimatedGifImage( + url: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Fit, +) { + var gifFrames by remember(url) { mutableStateOf(null) } + var currentFrame by remember(url) { mutableIntStateOf(0) } + var loadFailed by remember(url) { mutableStateOf(false) } + + LaunchedEffect(url) { + currentFrame = 0 + loadFailed = false + gifFrames = withContext(Dispatchers.IO) { decodeGifFrames(url) } + if (gifFrames == null) loadFailed = true + } + + // Reset frame index when frames change + DisposableEffect(url) { + onDispose { currentFrame = 0 } + } + + val data = gifFrames + when { + data != null && data.frames.size > 1 -> { + LaunchedEffect(data) { + while (isActive) { + val duration = data.durations[currentFrame].coerceAtLeast(MIN_FRAME_DURATION_MS) + delay(duration.toLong()) + currentFrame = (currentFrame + 1) % data.frames.size + } + } + + Image( + bitmap = data.frames[currentFrame], + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } + + data != null -> { + Image( + bitmap = data.frames[0], + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } + + loadFailed -> { + AsyncImage( + model = url, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } + + else -> { + Box(modifier) + } + } +} + +private fun decodeGifFrames(url: String): GifFrames? = + try { + val request = Request.Builder().url(url).build() + val response = gifHttpClient.newCall(request).execute() + val bytes = response.body.bytes() + + val skData = Data.makeFromBytes(bytes) + val codec = Codec.makeFromData(skData) + val frameCount = codec.frameCount + if (frameCount <= 0) return null + + val frameBitmapSize = codec.width.toLong() * codec.height * 4 + val totalMemory = frameBitmapSize * frameCount + val decodableFrames = + if (totalMemory > MAX_BITMAP_MEMORY) { + // Only decode first frame for huge GIFs + 1 + } else { + frameCount + } + + val frameInfos = codec.framesInfo + val frames = ArrayList(decodableFrames) + val durations = ArrayList(decodableFrames) + + for (i in 0 until decodableFrames) { + val bitmap = Bitmap() + bitmap.allocN32Pixels(codec.width, codec.height) + codec.readPixels(bitmap, i) + bitmap.setImmutable() + frames.add(bitmap.asComposeImageBitmap()) + durations.add(if (frameInfos.size > i) frameInfos[i].duration else 100) + } + + GifFrames(frames, durations) + } catch (e: Exception) { + println("AnimatedGif: failed to load $url — ${e.message}") + null + } 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 0b6bd0c4f..c02f4167b 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 @@ -37,106 +37,26 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue 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.player.base.MediaPlayer -import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer -/** - * Audio-only player using VLCJ with pooled audio players. - * Uses VlcjPlayerPool's shared audio factory instead of creating one per instance. - */ @Composable fun AudioPlayer( url: String, modifier: Modifier = Modifier, ) { - var isPlaying by remember { mutableStateOf(false) } - var position by remember { mutableFloatStateOf(0f) } - var duration by remember { mutableLongStateOf(0L) } - var currentTime by remember { mutableLongStateOf(0L) } - var vlcAvailable by remember { mutableStateOf(true) } - var player by remember { mutableStateOf(null) } + val audioState by GlobalMediaPlayer.audioState.collectAsState() + val isActiveAudio = audioState.url == url - DisposableEffect(url) { - val mp = VlcjPlayerPool.acquireAudioPlayer() - if (mp == null) { - vlcAvailable = false - return@DisposableEffect onDispose {} - } - - val listener = - object : MediaPlayerEventAdapter() { - override fun playing(mediaPlayer: MediaPlayer) { - isPlaying = true - duration = mediaPlayer.status().length() - } - - override fun paused(mediaPlayer: MediaPlayer) { - isPlaying = false - } - - override fun stopped(mediaPlayer: MediaPlayer) { - isPlaying = false - } - - override fun positionChanged( - mediaPlayer: MediaPlayer, - newPosition: Float, - ) { - position = newPosition - currentTime = (newPosition * duration).toLong() - } - - override fun finished(mediaPlayer: MediaPlayer) { - isPlaying = false - position = 0f - currentTime = 0L - } - } - - mp.events().addMediaPlayerEventListener(listener) - player = mp - - onDispose { - player = null - mp.events().removeMediaPlayerEventListener(listener) - VlcjPlayerPool.releaseAudioPlayer(mp) - } - } - - // Position polling - LaunchedEffect(isPlaying) { - while (isPlaying) { - delay(500) - player?.let { - position = it.status().position() - currentTime = it.status().time() - } - } - } - - if (!vlcAvailable) { - Text( - "Audio: $url (install VLC to play)", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = modifier, - ) - return - } + val isPlaying = if (isActiveAudio) audioState.isPlaying else false + val position = if (isActiveAudio) audioState.position else 0f + val duration = if (isActiveAudio) audioState.duration else 0L + val currentTime = if (isActiveAudio) audioState.currentTime else 0L Row( modifier = @@ -157,16 +77,10 @@ fun AudioPlayer( IconButton( onClick = { - player?.let { p -> - if (isPlaying) { - p.controls().pause() - } else { - if (position <= 0f && !p.status().isPlaying) { - p.media().play(url) - } else { - p.controls().play() - } - } + if (isActiveAudio) { + GlobalMediaPlayer.toggleAudioPlayPause() + } else { + GlobalMediaPlayer.playAudio(url) } }, modifier = Modifier.size(32.dp), @@ -185,7 +99,7 @@ fun AudioPlayer( Slider( value = position, - onValueChange = { player?.controls()?.setPosition(it) }, + onValueChange = { GlobalMediaPlayer.seekAudio(it) }, modifier = Modifier.weight(1f), ) 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 585015025..ae3b082bc 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 @@ -31,13 +31,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -45,27 +42,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import org.jetbrains.skia.Bitmap -import org.jetbrains.skia.ColorAlphaType -import org.jetbrains.skia.ImageInfo -import uk.co.caprica.vlcj.player.base.MediaPlayer -import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter -import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat -import java.nio.ByteBuffer -import java.util.UUID -import org.jetbrains.skia.Image as SkiaImage @Composable fun DesktopVideoPlayer( @@ -78,29 +60,18 @@ fun DesktopVideoPlayer( onViewModeChange: ((ViewMode) -> Unit)? = null, trailingControls: @Composable (() -> Unit)? = null, ) { - var frame by remember { mutableStateOf(null) } - var isPlaying by remember { mutableStateOf(false) } - var isBuffering by remember { mutableStateOf(false) } - var position by remember { mutableFloatStateOf(0f) } - var duration by remember { mutableLongStateOf(0L) } - var currentTime by remember { mutableLongStateOf(0L) } - var aspectRatio by remember { mutableFloatStateOf(16f / 9f) } - var vlcAvailable by remember { mutableStateOf(true) } - var player by remember { mutableStateOf(null) } - var volume by remember { mutableIntStateOf(100) } - var isMuted by remember { mutableStateOf(false) } + // Check if this URL is the active video + val videoState by GlobalMediaPlayer.videoState.collectAsState() + val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() + val isActiveVideo = videoState.url == url - // Unique ID for single-player enforcement - val playerId = remember { UUID.randomUUID().toString() } - - // Lazy activation — don't touch VLC until user clicks play (or autoPlay) - var activated by remember { mutableStateOf(autoPlay) } - - // Load thumbnail when not activated, with retry on failure + // Thumbnail for inactive videos var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) } - LaunchedEffect(url, activated) { - if (!activated && thumbnail == null) { - // Retry up to 3 times with increasing delay + var aspectRatio by remember { mutableFloatStateOf(16f / 9f) } + + // Load thumbnail when not active + LaunchedEffect(url, isActiveVideo) { + if (!isActiveVideo && thumbnail == null) { for (attempt in 1..3) { val result = VideoThumbnailCache.getThumbnail(url) if (result != null) { @@ -112,153 +83,24 @@ fun DesktopVideoPlayer( } } - // Pause when another player becomes active - val activeId by ActiveMediaManager.activeId.collectAsState() - LaunchedEffect(activeId) { - if (activeId != null && activeId != playerId && isPlaying) { - player?.controls()?.pause() + // Auto-play on mount if requested + LaunchedEffect(url, autoPlay) { + if (autoPlay) { + GlobalMediaPlayer.playVideo(url, initialSeekPosition) } } - // Set up player off the UI thread when activated - LaunchedEffect(url, activated) { - if (!activated) return@LaunchedEffect - isBuffering = true - - val acquired = - withContext(Dispatchers.IO) { - if (!VlcjPlayerPool.init()) return@withContext null - VlcjPlayerPool.acquire() - } - - if (acquired == null) { - vlcAvailable = false - isBuffering = false - return@LaunchedEffect - } - - var skBitmap: Bitmap? = null - var pixelBytes: ByteArray? = null - var didSeek = initialSeekPosition <= 0f - - val bufferFormatCallback = - object : BufferFormatCallback { - override fun getBufferFormat( - sourceWidth: Int, - sourceHeight: Int, - ): BufferFormat { - if (sourceHeight > 0) { - aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat() - } - val bmp = Bitmap() - bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL)) - skBitmap = bmp - pixelBytes = ByteArray(sourceWidth * sourceHeight * 4) - return RV32BufferFormat(sourceWidth, sourceHeight) - } - - override fun allocatedBuffers(buffers: Array) {} - } - - val renderCallback = - RenderCallback { _, nativeBuffers, _ -> - val bmp = skBitmap ?: return@RenderCallback - val bytes = pixelBytes ?: return@RenderCallback - val buffer = nativeBuffers[0] - buffer.rewind() - buffer.get(bytes) - bmp.installPixels(bytes) - frame = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() - } - - val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback) - acquired.videoSurface().set(surface) - - acquired.events().addMediaPlayerEventListener( - object : MediaPlayerEventAdapter() { - override fun playing(mediaPlayer: MediaPlayer) { - isPlaying = true - isBuffering = false - duration = mediaPlayer.status().length() - // Seek to initial position on first play - if (!didSeek) { - didSeek = true - mediaPlayer.controls().setPosition(initialSeekPosition) - } - } - - override fun paused(mediaPlayer: MediaPlayer) { - isPlaying = false - } - - override fun stopped(mediaPlayer: MediaPlayer) { - isPlaying = false - isBuffering = false - } - - override fun buffering( - mediaPlayer: MediaPlayer, - newCache: Float, - ) { - isBuffering = newCache < 100f - } - - override fun positionChanged( - mediaPlayer: MediaPlayer, - newPosition: Float, - ) { - position = newPosition - currentTime = (newPosition * duration).toLong() - } - - override fun finished(mediaPlayer: MediaPlayer) { - isPlaying = false - isBuffering = false - position = 0f - currentTime = 0L - } - - override fun error(mediaPlayer: MediaPlayer) { - isBuffering = false - println("VLC: playback error for $url") - } - }, - ) - - player = acquired - ActiveMediaManager.activate(playerId) - acquired.media().play(url) + // Sync aspect ratio from global state when active + if (isActiveVideo && videoState.aspectRatio != 16f / 9f) { + aspectRatio = videoState.aspectRatio } - // Clean up player on leave or URL change - DisposableEffect(url) { - onDispose { - ActiveMediaManager.deactivate(playerId) - player?.let { p -> - VlcjPlayerPool.release(p) - player = null - } - } - } - - // Position polling (VLCJ events sometimes miss updates) - LaunchedEffect(isPlaying) { - while (isPlaying) { - delay(500) - player?.let { - position = it.status().position() - currentTime = it.status().time() - } - } - } - - if (!vlcAvailable) { + if (!VlcjPlayerPool.isAvailable() && VlcjPlayerPool.init().not()) { VlcNotAvailableMessage(url, modifier) return } BoxWithConstraints(modifier = modifier) { - // Calculate height from aspect ratio, clamped to max constraints val desiredHeight = maxWidth / aspectRatio val constrainedHeight = if (constraints.hasBoundedHeight) minOf(desiredHeight, maxHeight) else desiredHeight @@ -273,7 +115,7 @@ fun DesktopVideoPlayer( ), contentAlignment = Alignment.Center, ) { - val displayBitmap = frame ?: thumbnail + val displayBitmap: ImageBitmap? = if (isActiveVideo) videoFrame ?: thumbnail else thumbnail displayBitmap?.let { bitmap -> Image( bitmap = bitmap, @@ -287,47 +129,38 @@ fun DesktopVideoPlayer( } VideoControls( - isPlaying = isPlaying, - isBuffering = isBuffering, - position = position, - duration = duration, - currentTime = currentTime, - volume = volume, - isMuted = isMuted, + isPlaying = if (isActiveVideo) videoState.isPlaying else false, + isBuffering = if (isActiveVideo) videoState.isBuffering else false, + position = if (isActiveVideo) videoState.position else 0f, + duration = if (isActiveVideo) videoState.duration else 0L, + currentTime = if (isActiveVideo) videoState.currentTime else 0L, + volume = if (isActiveVideo) videoState.volume else 100, + isMuted = if (isActiveVideo) videoState.isMuted else false, viewMode = viewMode, onPlayPause = { - val p = player - if (p != null) { - if (isPlaying) { - p.controls().pause() - } else { - ActiveMediaManager.activate(playerId) - if (position <= 0f && !p.status().isPlaying) { - p.media().play(url) - isBuffering = true - } else { - p.controls().play() - } - } + if (isActiveVideo) { + GlobalMediaPlayer.toggleVideoPlayPause() } else { - // First play — activate lazy init - activated = true + GlobalMediaPlayer.playVideo(url, initialSeekPosition) } }, onSeek = { pos -> - player?.controls()?.setPosition(pos) + if (isActiveVideo) { + GlobalMediaPlayer.seekVideo(pos) + } }, onVolumeChange = { vol -> - volume = vol - player?.audio()?.setVolume(vol) + GlobalMediaPlayer.setVideoVolume(vol) }, onMuteToggle = { - isMuted = !isMuted - player?.audio()?.isMute = isMuted + GlobalMediaPlayer.toggleVideoMute() }, onFullscreen = if (onFullscreen != null) { - { onFullscreen(position) } + { + val pos = if (isActiveVideo) videoState.position else 0f + onFullscreen(pos) + } } else { null }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt new file mode 100644 index 000000000..18a59732f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt @@ -0,0 +1,137 @@ +/* + * 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.ui.media + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + +@Composable +fun GlobalFullscreenOverlay() { + val isFullscreen by GlobalMediaPlayer.isFullscreen.collectAsState() + val videoState by GlobalMediaPlayer.videoState.collectAsState() + val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() + + if (!isFullscreen || videoState.url == null) return + + val focusRequester = remember { FocusRequester() } + val awtWindow = LocalAwtWindow.current + val isImmersiveFullscreen = LocalIsImmersiveFullscreen.current + + // Enter native fullscreen + LaunchedEffect(isFullscreen) { + if (isFullscreen) { + isImmersiveFullscreen.value = true + awtWindow?.let { FullscreenHelper.enterFullscreen(it) } + focusRequester.requestFocus() + } + } + + // Restore on exit + DisposableEffect(Unit) { + onDispose { + isImmersiveFullscreen.value = false + if (FullscreenHelper.isFullscreen()) FullscreenHelper.exitFullscreen() + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(focusRequester) + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + when (event.key) { + Key.Escape -> { + GlobalMediaPlayer.exitFullscreen() + true + } + + Key.F -> { + GlobalMediaPlayer.exitFullscreen() + true + } + + Key.Spacebar -> { + GlobalMediaPlayer.toggleVideoPlayPause() + true + } + + else -> { + false + } + } + }, + contentAlignment = Alignment.Center, + ) { + // Video frame + videoFrame?.let { frame -> + Image( + bitmap = frame, + contentDescription = "Video fullscreen", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + + // Video controls overlay + VideoControls( + isPlaying = videoState.isPlaying, + isBuffering = videoState.isBuffering, + position = videoState.position, + duration = videoState.duration, + currentTime = videoState.currentTime, + volume = videoState.volume, + isMuted = videoState.isMuted, + viewMode = ViewMode.FULLSCREEN, + onPlayPause = { GlobalMediaPlayer.toggleVideoPlayPause() }, + onSeek = { GlobalMediaPlayer.seekVideo(it) }, + onVolumeChange = { GlobalMediaPlayer.setVideoVolume(it) }, + onMuteToggle = { GlobalMediaPlayer.toggleVideoMute() }, + onViewModeChange = { mode -> + if (mode == ViewMode.DEFAULT) { + GlobalMediaPlayer.exitFullscreen() + } + }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt index e9e731712..194bad9f7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt @@ -37,10 +37,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.MoreVert @@ -71,6 +73,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type @@ -231,7 +234,7 @@ fun LightboxOverlay( } Key.S -> { - if (event.isCtrlPressed) { + if (event.isCtrlPressed || event.isMetaPressed) { triggerSave() true } else { @@ -247,13 +250,19 @@ fun LightboxOverlay( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { - // Click on backdrop doesn't close — use X button or Esc + if (viewMode != ViewMode.FULLSCREEN) onDismiss() }, ) { // Main content — video or image if (isVideo) { Box( - modifier = contentModifier, + modifier = + contentModifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + // Consume clicks so backdrop dismiss doesn't fire + }, contentAlignment = Alignment.Center, ) { DesktopVideoPlayer( @@ -397,6 +406,36 @@ fun LightboxOverlay( } } + // Close button (top-left) — hidden in fullscreen + if (viewMode != ViewMode.FULLSCREEN) { + IconButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.TopStart).padding(8.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + + // Image counter (bottom-center) — hidden in fullscreen + if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) { + Text( + text = "${currentIndex + 1} / ${urls.size}", + color = Color.White, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) + .padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + // Navigation arrows — hidden in fullscreen if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) { if (currentIndex > 0) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt new file mode 100644 index 000000000..978ab591a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt @@ -0,0 +1,270 @@ +/* + * 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.ui.media + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer +import kotlinx.coroutines.launch + +enum class MediaType { AUDIO, VIDEO } + +@Composable +fun NowPlayingBar(modifier: Modifier = Modifier) { + val videoState by GlobalMediaPlayer.videoState.collectAsState() + val audioState by GlobalMediaPlayer.audioState.collectAsState() + val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() + + val hasVideo = videoState.url != null + val hasAudio = audioState.url != null + val visible = hasVideo || hasAudio + + // Show video bar if video is active, otherwise audio + val activeState = if (hasVideo) videoState else audioState + val activeType = if (hasVideo) MediaType.VIDEO else MediaType.AUDIO + + AnimatedVisibility( + visible = visible, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = modifier, + ) { + if (!visible) return@AnimatedVisibility + + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Mini video thumbnail or music icon + if (activeType == MediaType.VIDEO && videoFrame != null) { + Image( + bitmap = videoFrame!!, + contentDescription = "Video thumbnail", + modifier = + Modifier + .size(width = 48.dp, height = 36.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + ) + } else { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + // Play/pause + IconButton( + onClick = { + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.toggleVideoPlayPause() + } else { + GlobalMediaPlayer.toggleAudioPlayPause() + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (activeState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (activeState.isPlaying) "Pause" else "Play", + modifier = Modifier.size(20.dp), + ) + } + + // Current time + Text( + text = formatTime(activeState.currentTime), + style = MaterialTheme.typography.labelSmall, + ) + + // Seek bar + Slider( + value = activeState.position, + onValueChange = { + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.seekVideo(it) + } else { + GlobalMediaPlayer.seekAudio(it) + } + }, + modifier = Modifier.weight(1f), + colors = + SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + ), + ) + + // Duration + Text( + text = formatTime(activeState.duration), + style = MaterialTheme.typography.labelSmall, + ) + + // URL label (truncated) + Text( + text = activeState.url?.substringAfterLast('/')?.substringBefore('?') ?: "", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(120.dp), + ) + + // Volume / Mute + IconButton( + onClick = { + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.toggleVideoMute() + } else { + GlobalMediaPlayer.toggleAudioMute() + } + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + if (activeState.isMuted) { + Icons.AutoMirrored.Filled.VolumeOff + } else { + Icons.AutoMirrored.Filled.VolumeUp + }, + contentDescription = if (activeState.isMuted) "Unmute" else "Mute", + modifier = Modifier.size(16.dp), + ) + } + + Slider( + value = activeState.volume / 100f, + onValueChange = { + val vol = (it * 100).toInt() + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.setVideoVolume(vol) + } else { + GlobalMediaPlayer.setAudioVolume(vol) + } + }, + modifier = Modifier.width(80.dp), + colors = + SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + ), + ) + + // Save button + val scope = rememberCoroutineScope() + IconButton( + onClick = { + activeState.url?.let { url -> + scope.launch { + SaveMediaAction.saveMedia(url = url) + } + } + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Save, + contentDescription = "Save", + modifier = Modifier.size(16.dp), + ) + } + + // Fullscreen (video only) + if (activeType == MediaType.VIDEO) { + IconButton( + onClick = { GlobalMediaPlayer.toggleFullscreen() }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Fullscreen, + contentDescription = "Fullscreen", + modifier = Modifier.size(16.dp), + ) + } + } + + Spacer(Modifier.width(4.dp)) + + // Close/stop + IconButton( + onClick = { + if (activeType == MediaType.VIDEO) { + GlobalMediaPlayer.stopVideo() + } else { + GlobalMediaPlayer.stopAudio() + } + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Stop", + modifier = Modifier.size(16.dp), + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt index 0be25abb4..ba2159ccc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt @@ -66,30 +66,40 @@ fun PictureDisplay( Column { // Images for ((index, url) in imageUrls.withIndex()) { - AsyncImage( - model = url, - contentDescription = title, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 500.dp) - .clip( - if (index == 0 && title == null && description.isBlank()) { - RoundedCornerShape(8.dp) - } else if (index == 0) { - RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp) - } else { - RoundedCornerShape(0.dp) - }, - ).then( - if (onImageClick != null) { - Modifier.clickable { onImageClick(imageUrls, index) } - } else { - Modifier - }, - ), - contentScale = ContentScale.FillWidth, - ) + val imageModifier = + Modifier + .fillMaxWidth() + .heightIn(max = 500.dp) + .clip( + if (index == 0 && title == null && description.isBlank()) { + RoundedCornerShape(8.dp) + } else if (index == 0) { + RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp) + } else { + RoundedCornerShape(0.dp) + }, + ).then( + if (onImageClick != null) { + Modifier.clickable { onImageClick(imageUrls, index) } + } else { + Modifier + }, + ) + if (isAnimatedGifUrl(url)) { + AnimatedGifImage( + url = url, + contentDescription = title, + modifier = imageModifier, + contentScale = ContentScale.FillWidth, + ) + } else { + AsyncImage( + model = url, + contentDescription = title, + modifier = imageModifier, + contentScale = ContentScale.FillWidth, + ) + } } // Title + description below images 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 0b82e69f1..1dbd20837 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 @@ -194,7 +194,7 @@ fun VideoControls( Slider( value = volume / 100f, onValueChange = { onVolumeChange((it * 100).toInt()) }, - modifier = Modifier.width(80.dp), + modifier = Modifier.width(240.dp), colors = SliderDefaults.colors( thumbColor = Color.White, 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 8f5e0e832..9813b0807 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 @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.desktop.ui.media import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -52,6 +53,17 @@ fun ZoomableImage( modifier .fillMaxSize() .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + scale = 1f + offsetX = 0f + offsetY = 0f + }, + onTap = { + // Consume single taps so they don't propagate to backdrop + }, + ) + }.pointerInput(Unit) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() @@ -77,19 +89,29 @@ fun ZoomableImage( }, contentAlignment = Alignment.Center, ) { - AsyncImage( - model = url, - contentDescription = null, - modifier = - Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offsetX, - translationY = offsetY, - ), - contentScale = ContentScale.Fit, - ) + val imageModifier = + Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY, + ) + if (isAnimatedGifUrl(url)) { + AnimatedGifImage( + url = url, + contentDescription = null, + modifier = imageModifier, + contentScale = ContentScale.Fit, + ) + } else { + AsyncImage( + model = url, + contentDescription = null, + modifier = imageModifier, + contentScale = ContentScale.Fit, + ) + } } } 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 627965002..44417e19a 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui.note import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -57,9 +58,11 @@ import com.vitorpamplona.amethyst.commons.richtext.Urls import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState +import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote @@ -155,60 +158,69 @@ fun NoteCard( CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, ), - onClick = onClick ?: {}, ) { Column(modifier = Modifier.padding(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + // Header + text area — clickable to navigate to thread + Column( + modifier = + if (onClick != null) { + Modifier.clickable { onClick() } + } else { + Modifier + }, ) { - // Author with avatar Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = - if (onAuthorClick != null) { - Modifier.clickable { onAuthorClick(note.pubKeyHex) } - } else { - Modifier - }, ) { - UserAvatar( - userHex = note.pubKeyHex, - pictureUrl = note.profilePictureUrl, - size = 32.dp, - contentDescription = "Profile picture of ${note.pubKeyDisplay}", - ) + // Author with avatar + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + if (onAuthorClick != null) { + Modifier.clickable { onAuthorClick(note.pubKeyHex) } + } else { + Modifier + }, + ) { + UserAvatar( + userHex = note.pubKeyHex, + pictureUrl = note.profilePictureUrl, + size = 32.dp, + contentDescription = "Profile picture of ${note.pubKeyDisplay}", + ) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(8.dp)) + Text( + text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + } + + // Timestamp Text( - text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, + text = note.createdAt.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Timestamp - Text( - text = note.createdAt.toTimeAgo(withDot = false), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Spacer(Modifier.height(8.dp)) - Spacer(Modifier.height(8.dp)) - - if (strippedContent.isNotBlank()) { - RichTextContent( - content = strippedContent, - urls = strippedUrls, - localCache = localCache, - onMentionClick = onMentionClick, - modifier = Modifier.fillMaxWidth(), - ) - } + if (strippedContent.isNotBlank()) { + RichTextContent( + content = strippedContent, + urls = strippedUrls, + localCache = localCache, + onMentionClick = onMentionClick, + modifier = Modifier.fillMaxWidth(), + ) + } + } // end clickable header+text column // Inline images if (imageUrls.isNotEmpty()) { @@ -216,9 +228,7 @@ fun NoteCard( Spacer(Modifier.height(8.dp)) } for ((index, url) in imageUrls.withIndex()) { - AsyncImage( - model = url, - contentDescription = null, + Box( modifier = Modifier .fillMaxWidth() @@ -231,8 +241,23 @@ fun NoteCard( Modifier }, ), - contentScale = ContentScale.Fit, - ) + ) { + if (isAnimatedGifUrl(url)) { + AnimatedGifImage( + url = url, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Fit, + ) + } else { + AsyncImage( + model = url, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Fit, + ) + } + } if (url != imageUrls.last()) { Spacer(Modifier.height(4.dp)) } 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 130d20b43..31f7031e4 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui.settings +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -29,8 +30,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -40,12 +39,17 @@ import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -106,15 +110,19 @@ fun MediaServerSettings( Spacer(Modifier.height(16.dp)) // Server list - LazyColumn( + Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.weight(1f, fill = false), ) { - items(servers) { server -> + for (server in servers.toList()) { ServerRow( server = server, status = serverStatuses[server] ?: ServerHealthCheck.ServerStatus.UNKNOWN, isDefault = servers.indexOf(server) == 0, + onSetDefault = { + servers.remove(server) + servers.add(0, server) + onServersChanged(servers.toList()) + }, onRemove = { servers.remove(server) serverStatuses.remove(server) @@ -152,7 +160,7 @@ fun MediaServerSettings( Button( onClick = { val url = newServerUrl.trim().removeSuffix("/") - if (url.isNotBlank() && url !in servers) { + if (url.isNotBlank() && url !in servers && isValidServerUrl(url)) { servers.add(url) newServerUrl = "" onServersChanged(servers.toList()) @@ -162,7 +170,7 @@ fun MediaServerSettings( } } }, - enabled = newServerUrl.isNotBlank(), + enabled = newServerUrl.isNotBlank() && isValidServerUrl(newServerUrl.trim()), ) { Icon(Icons.Default.Add, contentDescription = "Add") Spacer(Modifier.width(4.dp)) @@ -202,11 +210,23 @@ fun MediaServerSettings( } } +private fun isValidServerUrl(url: String): Boolean { + val trimmed = url.trim().removeSuffix("/") + return try { + val uri = java.net.URI(trimmed) + uri.scheme in listOf("https", "http") && uri.host != null && uri.host.contains(".") + } catch (_: Exception) { + false + } +} + +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ServerRow( server: String, status: ServerHealthCheck.ServerStatus, isDefault: Boolean, + onSetDefault: () -> Unit, onRemove: () -> Unit, onRefresh: () -> Unit, ) { @@ -221,17 +241,33 @@ private fun ServerRow( modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Status indicator - Surface( - modifier = Modifier.size(12.dp), - shape = CircleShape, - color = - when (status) { - ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50) - ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336) - ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E) - }, - ) {} + // Status indicator with tooltip + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text( + when (status) { + ServerHealthCheck.ServerStatus.ONLINE -> "Online" + ServerHealthCheck.ServerStatus.OFFLINE -> "Offline — server unreachable" + ServerHealthCheck.ServerStatus.UNKNOWN -> "Checking..." + }, + ) + } + }, + state = rememberTooltipState(), + ) { + Surface( + modifier = Modifier.size(12.dp), + shape = CircleShape, + color = + when (status) { + ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50) + ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336) + ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E) + }, + ) {} + } Spacer(Modifier.width(12.dp)) @@ -246,6 +282,13 @@ private fun ServerRow( style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, ) + } else { + Text( + "Set as default", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clickable { onSetDefault() }, + ) } } diff --git a/docs/brainstorms/2026-03-16-blossom-protocol-research.md b/docs/brainstorms/2026-03-16-blossom-protocol-research.md new file mode 100644 index 000000000..103034268 --- /dev/null +++ b/docs/brainstorms/2026-03-16-blossom-protocol-research.md @@ -0,0 +1,604 @@ +# Blossom Protocol Research + +**Date**: 2026-03-16 +**Sources**: hzrd149/blossom GitHub (BUD specs), NIP-B7, Nostrify docs, Amethyst upstream codebase, Primal blog posts + +--- + +## Overview + +Blossom (**Bl**obs **O**n **S**imple **S**erver**om**... or something) is a specification for HTTP endpoints that let users store binary blobs on publicly accessible servers. Blobs are content-addressed by their **SHA-256 hash**. Uses Nostr keypairs for identity and authorization. + +**Two Nostr event kinds:** +- **Kind 24242** -- Authorization token (BUD-11) +- **Kind 10063** -- User's Blossom server list (BUD-03, NIP-B7) + +**BUD index (BUD-00 through BUD-11):** + +| BUD | Name | Status | Required | +|-----|------|--------|----------| +| 00 | BUD framework | - | - | +| 01 | Server requirements + blob retrieval | draft | mandatory | +| 02 | Upload + management | draft | optional | +| 03 | User server list (kind 10063) | draft | optional | +| 04 | Mirroring | draft | optional | +| 05 | Media optimization | draft | optional | +| 06 | Upload requirements (HEAD preflight) | draft | optional | +| 07 | Payment required (402) | draft | optional | +| 08 | NIP-94 file metadata tags | draft | optional | +| 09 | Blob report | draft | optional | +| 10 | Blossom URI scheme | draft | optional | +| 11 | Nostr authorization | draft | optional | + +--- + +## BUD-01: Server Requirements + Blob Retrieval + +**Status:** `draft` `mandatory` + +### CORS + +All responses MUST set `Access-Control-Allow-Origin: *`. + +Preflight (`OPTIONS`) responses MUST also set: +``` +Access-Control-Allow-Headers: Authorization, * +Access-Control-Allow-Methods: GET, HEAD, PUT, DELETE +``` + +MAY set `Access-Control-Max-Age: 86400` (cache 24h). + +### Error Responses + +Any 4xx/5xx response MAY include `X-Reason` header with human-readable error message. + +### Endpoints + +All endpoints served from domain root. No path prefix. + +#### GET / -- Get Blob + +```http +GET /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1 +Host: cdn.example.com +``` + +Response: +```http +HTTP/1.1 200 OK +Content-Type: application/pdf +Content-Length: 184292 + + +``` + +- MUST accept optional file extension in URL (`.pdf`, `.png`, etc.) +- MUST return correct `Content-Type` regardless of extension +- MUST default to `application/octet-stream` if MIME unknown +- MAY require authorization (BUD-11) + +**Proxying/Redirection:** +- 3xx redirects MUST redirect to URL containing same SHA-256 hash +- Destination MUST set `Access-Control-Allow-Origin: *`, `Content-Type`, `Content-Length` + +**Range Requests:** +- Servers SHOULD support `Range` header (RFC 7233) on GET +- Signal via `Accept-Ranges: bytes` and `Content-Length` on HEAD + +#### HEAD / -- Has Blob + +Identical to GET but MUST NOT return body. MUST return same `Content-Type` and `Content-Length` headers. + +--- + +## BUD-02: Upload + Management + +**Status:** `draft` `optional` + +### Blob Descriptor + +The standard JSON response for all upload/mirror operations: + +```json +{ + "url": "https://cdn.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf", + "sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553", + "size": 184292, + "type": "application/pdf", + "uploaded": 1725105921 +} +``` + +Fields: +- `url` -- Public URL to `GET /` endpoint **with file extension** +- `sha256` -- Hex-encoded SHA-256 of the blob +- `size` -- Size in bytes +- `type` -- MIME type (fallback `application/octet-stream`) +- `uploaded` -- Unix timestamp + +MAY include: `magnet`, `infohash`, `ipfs` + +### PUT /upload -- Upload Blob + +```http +PUT /upload HTTP/1.1 +Host: cdn.example.com +Authorization: Nostr +Content-Type: image/png +Content-Length: 184292 +X-SHA-256: b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553 + + +``` + +Response (success): +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "url": "https://cdn.example.com/b167...553.png", + "sha256": "b167...553", + "size": 184292, + "type": "image/png", + "uploaded": 1725105921 +} +``` + +Key rules: +- Server MUST NOT modify the blob +- Server MUST compute SHA-256 over exact bytes received +- Client SHOULD include `Content-Type` and `Content-Length` +- Client MAY provide `X-SHA-256` header (hex lowercase) +- Server MAY use `X-SHA-256` for pre-upload rejection policies +- Success: 2xx with Blob Descriptor +- Failure: 4xx with error message + +### GET /list/ -- List Blobs (Unrecommended) + +Optional. Returns JSON array of Blob Descriptors for a pubkey. + +Query params: +- `cursor` -- SHA-256 of last blob (cursor-based pagination) +- `limit` -- Max results +- `since`/`until` -- Filter by upload date (deprecated for pagination) + +Sorted by `uploaded` descending. + +### DELETE / -- Delete Blob + +```http +DELETE /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1 +Host: cdn.example.com +Authorization: Nostr +``` + +- Multiple `x` tags in auth token MUST NOT be interpreted as batch delete + +--- + +## BUD-03: User Server List + +**Kind 10063** (replaceable event). + +```json +{ + "kind": 10063, + "tags": [ + ["server", "https://cdn.self.hosted"], + ["server", "https://cdn.satellite.earth"], + ["alt", "File servers used by the author"] + ], + "content": "" +} +``` + +- Tag order = priority. Most trusted/reliable first. +- Clients MUST upload to at least the first server in user's list. +- Clients MAY mirror to other listed servers via BUD-04. + +**Discovery flow when URL breaks:** +1. Extract 64-char hex hash from broken URL +2. Fetch author's kind:10063 event +3. Try each listed server in order +4. Fall back to well-known servers + +--- + +## BUD-04: Mirroring + +**Status:** `draft` `optional` + +### PUT /mirror -- Mirror Blob + +```http +PUT /mirror HTTP/1.1 +Host: backup-server.example.com +Authorization: Nostr +Content-Type: application/json + +{ + "url": "https://cdn.satellite.earth/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf" +} +``` + +Response: Blob Descriptor (same as upload). + +Key rules: +- Server downloads blob from provided URL +- Server SHOULD use `Content-Type` from origin server +- Server verifies downloaded blob hash matches `x` tag in auth token +- Returns 2xx + Blob Descriptor on success, 4xx on failure + +**Typical flow:** +1. Client uploads to Server A, gets Blob Descriptor with URL +2. Client sends URL to Server B's `/mirror` with same upload auth token +3. Server B downloads from Server A +4. Server B verifies hash matches `x` tag +5. Server B returns Blob Descriptor + +--- + +## BUD-05: Media Optimization + +**Status:** `draft` `optional` + +### PUT /media -- Optimized Upload + +```http +PUT /media HTTP/1.1 +Host: trusted-server.example.com +Authorization: Nostr +Content-Type: image/png +Content-Length: 4194304 + + +``` + +Response: Blob Descriptor -- but hash will differ from input because server transforms the file. + +Key differences from `/upload`: +- Server MAY modify/optimize the blob (strip EXIF, compress, transcode) +- The returned SHA-256 will be of the **optimized** blob, not the original +- Client has NO control over optimization process +- `t` tag in auth event must be `media` (not `upload`) + +### HEAD /media + +Same as HEAD /upload (BUD-06) but for the media endpoint. + +### Client Implementation Pattern + +1. User selects a "trusted processing" server +2. Client uploads original media to `/media` on trusted server +3. Gets back optimized blob descriptor (new hash) +4. Client signs new upload auth for the optimized hash +5. Calls `/mirror` on other servers to distribute the optimized blob + +This is what Primal does -- all Primal 2.2+ apps use `/media` by default, strips metadata, then mirrors. + +--- + +## BUD-06: Upload Requirements (HEAD Preflight) + +**Status:** `draft` `optional` + +### HEAD /upload -- Pre-flight Check + +Client sends blob metadata, server says yes/no before actual upload. + +Request: +```http +HEAD /upload HTTP/1.1 +Host: cdn.example.com +X-Content-Type: application/pdf +X-Content-Length: 184292 +X-SHA-256: 88a74d0b866c8ba79251a11fe5ac807839226870e77355f02eaf68b156522576 +Authorization: Nostr +``` + +Success: +```http +HTTP/1.1 200 OK +``` + +Failure examples: +```http +HTTP/1.1 400 Bad Request +X-Reason: Invalid X-SHA-256 header format. Expected a string. + +HTTP/1.1 401 Unauthorized +X-Reason: Authorization required for uploading video files. + +HTTP/1.1 403 Forbidden +X-Reason: SHA-256 hash banned. + +HTTP/1.1 411 Length Required +X-Reason: Missing X-Content-Length header. + +HTTP/1.1 413 Content Too Large +X-Reason: File too large. Max allowed size is 100MB. + +HTTP/1.1 415 Unsupported Media Type +X-Reason: Unsupported file type. +``` + +**Note:** Uses `X-Content-Type`, `X-Content-Length`, `X-SHA-256` headers (not standard `Content-*`). + +--- + +## BUD-07: Payment Required + +Servers MAY return `402 Payment Required` with payment method headers: + +```http +HTTP/1.1 402 Payment Required +X-Cashu: "" +X-Lightning: "" +``` + +After payment, client retries with proof: +- Cashu: serialized `cashuB` token per NUT-24 +- Lightning: preimage of the BOLT-11 payment + +HEAD requests inform about cost but should not be retried with payment; proceed to PUT/GET after paying. + +--- + +## BUD-08: NIP-94 File Metadata Tags + +Servers MAY include a `nip94` field in Blob Descriptor responses: + +```json +{ + "url": "https://cdn.example.com/b167...553.pdf", + "sha256": "b167...553", + "size": 184292, + "type": "application/pdf", + "uploaded": 1725105921, + "nip94": [ + ["url", "https://cdn.example.com/b167...553.pdf"], + ["m", "application/pdf"], + ["x", "b167...553"], + ["size", "184292"], + ["magnet", "magnet:?xt=urn:btih:..."], + ["i", "infohash-here"] + ] +} +``` + +Follows NIP-94 tag format as KV pairs. Allows clients to get standardized metadata without separate requests. + +--- + +## BUD-09: Blob Report + +### PUT /report + +Body: signed NIP-56 report event (kind 1984): + +```json +{ + "kind": 1984, + "content": "This blob contains illegal content", + "tags": [ + ["x", "", "illegal"], + ["p", ""] + ] +} +``` + +Server maintains records for operator review. Optionally authorizes trusted moderators for autonomous removal. + +--- + +## BUD-10: Blossom URI Scheme + +Format: +``` +blossom:.[?param1=value1¶m2=value2...] +``` + +Example: +``` +blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.example.com&xs=backup.example.com&as=&sz=184292 +``` + +Query parameters: +- `xs` -- Server domain hints (tried first). Repeatable. +- `as` -- Author hex pubkey for BUD-03 server list lookup. Repeatable. +- `sz` -- Size in bytes. + +**Resolution priority:** +1. Direct server hints (`xs`) via `GET /` +2. Author server lists (fetch kind:10063 for each `as` pubkey) +3. Fallback to well-known servers or local cache + +--- + +## BUD-11: Authorization + +### Kind 24242 Event Structure + +```json +{ + "id": "", + "pubkey": "", + "created_at": 1725105921, + "kind": 24242, + "tags": [ + ["t", "upload"], + ["x", "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"], + ["expiration", "1725109521"], + ["server", "cdn.example.com"] + ], + "content": "Upload cat photo", + "sig": "" +} +``` + +### Required Tags + +| Tag | Description | +|-----|-------------| +| `t` | Action verb: `get`, `upload`, `list`, `delete`, `media` | +| `expiration` | Unix timestamp when token expires (NIP-40) | + +### Optional Tags + +| Tag | Description | +|-----|-------------| +| `x` | SHA-256 hash of specific blob. Multiple allowed. | +| `server` | Domain restriction (lowercase). Multiple allowed. | +| `size` | File size in bytes (Amethyst adds this) | + +### Authorization Header + +``` +Authorization: Nostr +``` + +Note: Amethyst uses standard Base64 (not Base64url), which works in practice. + +### Endpoint Authorization Requirements + +| Endpoint | `t` tag | Hash source | `x` tag | +|----------|---------|-------------|---------| +| GET/HEAD / | `get` | URL path | optional | +| PUT /upload | `upload` | X-SHA-256 header | required | +| HEAD /upload | `upload` | X-SHA-256 header | required | +| DELETE / | `delete` | URL path | required | +| GET /list/ | `list` | -- | N/A | +| PUT /mirror | `upload` | mirrored blob hash | required | +| PUT /media | `media` | X-SHA-256 header | required | +| HEAD /media | `media` | X-SHA-256 header | required | + +### Validation Checklist (Server) + +1. Event kind == 24242 +2. `created_at` < now +3. `expiration` > now +4. `t` tag matches endpoint action +5. `server` tags (if present) include this server's domain +6. `x` tags (if required) match the blob hash + +### Security Note + +Unscoped tokens (no `server` tag) can be replayed to other servers. Always scope `delete` tokens. + +--- + +## Error Handling -- HTTP Status Codes + +| Code | Meaning | When | +|------|---------|------| +| 200 | Success | GET, HEAD, successful upload/mirror/delete | +| 3xx | Redirect | GET with CDN redirect (must preserve hash in URL) | +| 400 | Bad Request | Invalid headers, malformed auth | +| 401 | Unauthorized | Missing or invalid authorization | +| 402 | Payment Required | BUD-07 paid servers | +| 403 | Forbidden | Hash banned, user blocked | +| 404 | Not Found | Blob doesn't exist | +| 411 | Length Required | Missing Content-Length / X-Content-Length | +| 413 | Content Too Large | File exceeds server limit | +| 415 | Unsupported Media Type | Server doesn't accept this MIME type | + +All error responses MAY include `X-Reason` header. + +--- + +## Popular Blossom Servers + +| Server | Limits | Notes | +|--------|--------|-------| +| `blossom.nostr.build` | 100 MiB hard, 20 MiB free | Run by nostr.build team. Supports BUD-01,02,04,05,06,08 | +| `blossom.band` | 100 MiB hard, 20 MiB free | Community server | +| `blossom.primal.net` | Integrated with Primal stack | Uses /media by default, strips metadata | +| `cdn.satellite.earth` | Unknown | Satellite CDN | +| `blossom.azzamo.net` | Free tier + premium | Azzamo's server | +| `blosstr.com` | Enterprise-grade | Commercial offering | + +Rate limiting is not standardized in the protocol. Each server implements its own policies. Free tiers generally have stricter limits. BUD-06 HEAD preflight is the mechanism for discovering server limitations before uploading. + +--- + +## Client Implementations + +### Amethyst (Kotlin -- upstream) + +**Quartz library** (`quartz/src/commonMain/kotlin/.../nipB7Blossom/`): +- `BlossomAuthorizationEvent` -- Kind 24242 event creation (get, upload, delete, list) +- `BlossomServersEvent` -- Kind 10063 server list management +- `BlossomUploadResult` -- Blob Descriptor deserialization (kotlinx.serialization) +- `BlossomUri` -- BUD-10 URI parsing/serialization + +**Android app** (`amethyst/src/main/java/.../service/uploads/blossom/`): +- `BlossomUploader` -- PUT /upload + DELETE implementation using OkHttp +- `BlossomServerResolver` -- BUD-10 URI resolution with LruCache +- `ServerHeadCache` -- HEAD request caching for blob existence checks +- `UploadOrchestrator` -- Orchestrates NIP-95, NIP-96, and Blossom uploads + +**Upload flow:** +1. Read file, compute SHA-256 hash + size +2. Compute blurhash metadata locally +3. Create kind 24242 auth event (t=upload, x=hash, expiration=+1hr) +4. Base64-encode auth event JSON +5. PUT /upload with `Authorization: Nostr `, `Content-Type`, `Content-Length` +6. Parse Blob Descriptor response +7. Download + verify the uploaded file (re-hash check) + +**Key:** Amethyst does NOT use `/media` endpoint. Uses `/upload` only. No mirroring implemented. + +### Primal + +- All Primal 2.2+ apps use `/media` (BUD-05) by default +- Strips all metadata before saving +- Optionally mirrors to other Blossom servers per user settings +- Deeply integrated into Primal stack, enabled by default + +### Nostrify (TypeScript/Web) + +```typescript +const uploader = new BlossomUploader({ + servers: ['https://blossom.primal.net/'], + signer: window.nostr, + expiresIn: 60, // seconds +}); + +const tags = await uploader.upload(file); +// Returns NIP-94 tags: url, x, ox, size, m +``` + +- `ox` tag = original hash (before server processing) +- `x` tag = final hash (after optimization if /media used) + +### NDK Blossom (@nostr-dev-kit/ndk-blossom) + +npm package wrapping BUD-01 through BUD-06. TypeScript. + +### Dart NDK (dart-nostr.com) + +Has Blossom use case documentation. Flutter/Dart integration. + +--- + +## Key Protocol Design Decisions + +1. **Content addressing via SHA-256** -- Same file = same hash everywhere. Deduplication is free. +2. **Servers are interchangeable** -- Any server with the blob can serve it. URLs break? Find the hash elsewhere. +3. **No server-side processing on /upload** -- Bit-perfect storage. Hash computed over exact bytes received. +4. **/media is the exception** -- Trusted server processes/optimizes. New hash for result. +5. **Authorization is opt-in per endpoint** -- Servers choose what to protect. +6. **User controls server list** -- Kind 10063 event = user's preferred servers. +7. **Mirror for redundancy** -- Upload once, mirror to N servers. + +--- + +## Unanswered Questions + +- Does BUD-11 require base64url (no padding) or standard base64? Spec says base64url, Amethyst uses standard base64 -- servers seem to accept both. +- What's the recommended expiration window for auth tokens? Amethyst uses 1 hour. +- How do clients handle the `/media` flow when the optimized hash differs from original? Need to re-sign auth for mirror requests with the new hash. +- Is there a standard way to discover server capabilities (which BUDs supported)? Not currently -- no capability endpoint defined. +- How to handle upload failures mid-stream for large files? No chunked upload in spec. +- Server-side dedup behavior when same hash uploaded by different users? Implementation-specific. diff --git a/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md b/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md new file mode 100644 index 000000000..7793b1090 --- /dev/null +++ b/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md @@ -0,0 +1,468 @@ +# Brainstorm: Desktop Media — Full Parity + +**Date:** 2026-03-16 +**Status:** Draft +**Branch:** TBD (`feat/desktop-media`) + +## What We're Building + +Full media functionality for Amethyst Desktop — display, upload, gallery, lightbox, video playback, encrypted media, and desktop-native UX. Feature parity with Android Amethyst's media stack, adapted for mouse-first desktop interaction. + +### Scope + +| Feature | Included | Notes | +|---------|----------|-------| +| Image display in notes | Yes | Coil3 AsyncImage, blurhash previews | +| Video playback | Yes | VLCJ with bundled libvlc | +| Blossom upload | Yes | Extract to commons, Blossom-only (no NIP-96) | +| Drag-drop / clipboard paste | Yes | Essential desktop UX from day 1 | +| Lightbox / zoom | Yes | Full-screen media viewer with zoom | +| Image gallery carousel | Yes | Multi-image posts | +| Profile gallery (NIP-68) | Yes | Kind 20 picture posts, create + view | +| Video events (NIP-71) | Yes | Kind 21/22 display | +| Encrypted media (NIP-17 DMs) | Yes | Full send + receive | +| Media server management | Yes | Blossom server list (kind 10063) | +| Audio/voice playback | Yes | MP3, OGG, FLAC, WAV | +| Media compression | Yes | Desktop-adapted (Java ImageIO) | +| Blurhash generation on upload | Yes | Already in commons/ | +| EXIF stripping | Yes | Privacy: strip metadata before upload | +| Alt text / accessibility | Yes | NIP-92 imeta alt field | + +### Out of Scope (for now) + +- NIP-96 upload (deprecated, Blossom replaces it) +- Voice recording (microphone capture — desktop-specific, complex) +- Picture-in-picture video +- Live streaming (NIP-53) +- Torrent/magnet distribution + +## Why This Approach + +### Blossom Only (No NIP-96) + +NIP-96 is officially marked "unrecommended: replaced by blossom APIs" in the NIP registry. Building desktop from scratch gives us the opportunity to skip legacy protocol support entirely. + +**Blossom advantages:** +- Content-addressed (SHA-256) — files portable across servers +- Native mirroring (BUD-04) — upload once, mirror to N servers +- Server-side optimization (BUD-05) — `/media` endpoint +- Payment support (BUD-07) — Cashu/Lightning +- Clean URI scheme (BUD-10) — `blossom:.` + +### Extraction Strategy — Layered Architecture + +Android's BlossomUploader is tightly coupled to Android (`Context`, `Uri`, `ContentResolver`). We need to separate the HTTP upload protocol from platform file access. + +**Layer 1: commons/commonMain — Pure Blossom protocol client** +- `BlossomClient` — HTTP PUT /upload, /mirror, /media, DELETE, GET /list. Takes `InputStream` + metadata. Returns `BlobDescriptor`. No platform deps. +- `BlossomAuthHelper` — Creates kind 24242 auth events, base64-encodes for Authorization header +- `BlossomServerDiscovery` — Queries kind 10063, resolves server list, caches +- `MediaUploadResult` — Result data class (already platform-agnostic) +- `UploadOrchestrator` — Coordinates upload to server + optional optimization + mirroring +- `MultiUploadOrchestrator` — Manages parallel upload of multiple files +- `MediaUploadTracker` — StateFlow-based progress tracking +- `ServerHeadCache` — BUD-06 pre-flight response cache + +**Layer 2: commons/commonMain — expect/actual for platform file operations** +``` +expect fun readFileBytes(path: String): ByteArray +expect fun computeFileSha256(path: String): String +expect fun getMimeType(path: String): String? +expect fun getFileSize(path: String): Long +expect class MediaMetadataExtractor { + fun extractDimensions(path: String): Pair? + fun extractBlurhash(path: String): String? +} +expect class MediaCompressor { + fun compressImage(path: String, quality: Float): String + fun stripExif(path: String): String +} +``` + +**Layer 3: Platform actuals** +- `androidMain/` — Uses `ContentResolver`, `Uri`, Android Bitmap, `MediaMetadataRetriever` +- `jvmMain/` — Uses `java.io.File`, Java ImageIO, `metadata-extractor`, BufferedImage→blurhash + +**Layer 4: Platform UI (desktopApp/ and amethyst/)** +- File pickers, drag-drop handlers, video players, lightbox composables + +This design lets any future client (iOS, web) reuse Layer 1 + 2 by providing Layer 3 actuals. + +### Coil3 for Image Loading + +Coil3 officially supports Compose Multiplatform including JVM/Desktop. Android Amethyst already uses Coil3. Benefits: +- Disk + memory caching +- Custom fetchers (Blossom URI, blurhash, base64) +- Crossfade animations +- SVG support + +### VLCJ for Video + +ExoPlayer is Android-only (Media3). VLCJ wraps VLC's libvlc via JNA — supports every format VLC does (mp4, webm, m3u8, mkv, etc.). Compose integration via `SwingPanel` or offscreen rendering. We bundle libvlc with the app (~100MB) for zero user setup. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Upload protocol | Blossom only | NIP-96 deprecated | +| Upload code location | commons/commonMain | Share with Android | +| Image loading | Coil3 | Already used on Android, KMP support | +| Video playback | VLCJ (bundled libvlc) | ExoPlayer is Android-only | +| Desktop input | Drag-drop + paste + file picker | Essential for desktop UX | +| Encrypted media | Full support | DM file sharing parity | +| Profile gallery | Yes (NIP-68 kind 20) | Create + view | + +## Existing Codebase Audit + +### Already Shared (commons/) + +| Component | Location | Status | +|-----------|----------|--------| +| BlurHashDecoder/Encoder | `commons/blurhash/` | Ready | +| PlatformImage (expect/actual) | `commons/blurhash/PlatformImage.kt` | Ready (JVM uses BufferedImage) | +| BitmapUtils (JVM) | `commons/blurhash/BitmapUtils.jvm.kt` | Ready | +| Base64ImagePlatform (JVM) | `commons/base64Image/` | Ready | +| RichTextParser | `commons/richtext/RichTextParser.kt` | Ready (classifies URLs as image/video) | +| MediaContentModels | `commons/richtext/MediaContentModels.kt` | Ready (MediaUrlImage, MediaUrlVideo, etc.) | +| UrlParser | `commons/richtext/UrlParser.kt` | Ready | +| Image extensions list | RichTextParser companion | png, jpg, gif, bmp, jpeg, webp, svg, avif | +| Video extensions list | RichTextParser companion | mp4, avi, wmv, mpg, amv, webm, mov + audio | + +### In Quartz (protocol layer, shared) + +| Component | Location | Status | +|-----------|----------|--------| +| BlossomAuthorizationEvent | `quartz/nipB7Blossom/` | Ready (kind 24242) | +| BlossomServersEvent | `quartz/nipB7Blossom/` | Ready (kind 10063) | +| BlossomUri | `quartz/nipB7Blossom/` | Ready (blossom: URI parsing) | +| BlossomUploadResult | `quartz/nipB7Blossom/` | Ready | +| FileHeaderEvent (NIP-94) | `quartz/nip94FileMetadata/` | Ready (kind 1063) | +| BlurhashTag | `quartz/nip94FileMetadata/tags/` | Ready | +| DimensionTag | `quartz/nip94FileMetadata/tags/` | Ready | +| IMetaTag/Builder (NIP-92) | `quartz/nip92IMeta/` | Ready | +| PictureEvent (NIP-68) | `quartz/nip68Picture/` | Ready (kind 20) | +| VideoEvent (NIP-71) | `quartz/nip71Video/` | Ready (kind 21/22/34235/34236) | +| ProfileGalleryEntryEvent | `quartz/experimental/profileGallery/` | Ready | +| FileServersEvent (NIP-96) | `quartz/nip96FileStorage/` | Exists but we're skipping NIP-96 | +| ChatMessageEncryptedFileHeaderEvent | `quartz/nip17Dm/files/` | Ready (encrypted DM files) | + +### Android-Only (needs extraction or desktop equivalent) + +| Component | Location | Action | +|-----------|----------|--------| +| BlossomUploader | `amethyst/service/uploads/blossom/` | Extract HTTP logic to commons | +| Nip96Uploader | `amethyst/service/uploads/nip96/` | Skip (Blossom only) | +| UploadOrchestrator | `amethyst/service/uploads/` | Extract to commons | +| MultiOrchestrator | `amethyst/service/uploads/` | Extract to commons | +| MediaUploadResult | `amethyst/service/uploads/` | Extract (already platform-agnostic) | +| MediaCompressor | `amethyst/service/uploads/` | Desktop equivalent (Java ImageIO) | +| BlurhashMetadataCalculator | `amethyst/service/uploads/` | Desktop equivalent (Java ImageIO + commons blurhash) | +| BlossomServerResolver | `amethyst/service/uploads/blossom/bud10/` | Extract to commons | +| ServerHeadCache | `amethyst/service/uploads/blossom/bud10/` | Extract to commons | +| ImageLoaderSetup | `amethyst/service/images/` | Desktop Coil3 config | +| BlossomFetcher | `amethyst/service/images/` | Extract to commons (Coil3 fetcher for blossom: URIs) | +| BlurHashFetcher | `amethyst/service/images/` | Extract to commons (Coil3 fetcher for blurhash) | +| Base64Fetcher | `amethyst/service/images/` | Extract to commons | +| ZoomableContentView | `amethyst/ui/components/` | Desktop lightbox equivalent | +| ZoomableContentDialog | `amethyst/ui/components/` | Desktop lightbox dialog | +| ImageGallery | `amethyst/ui/components/` | Extract carousel to commons | +| MyAsyncImage | `amethyst/ui/components/` | Desktop equivalent | +| VideoView/VideoViewInner | `amethyst/service/playback/` | Desktop video player (VLCJ) | +| ExoPlayerPool/Builder | `amethyst/service/playback/` | Desktop player pool equivalent | +| MediaAspectRatioCache | `amethyst/model/` | Extract to commons | +| NewMediaView/Model | `amethyst/ui/actions/` | Desktop media post composer | +| MediaUploadTracker | `amethyst/ui/actions/uploads/` | Extract to commons | +| SelectFromGallery | `amethyst/ui/actions/uploads/` | Desktop file picker | +| ShowImageUploadItem | `amethyst/ui/actions/uploads/` | Extract upload preview to commons | +| ChatFileSender/Uploader | `amethyst/ui/screen/chats/` | Desktop encrypted upload | +| BlossomServersViewModel | `amethyst/ui/actions/mediaServers/` | Extract to commons | +| GalleryThumb | `amethyst/ui/screen/profile/gallery/` | Desktop gallery grid | +| PictureDisplay | `amethyst/ui/note/types/` | Extract to commons | +| VideoDisplay | `amethyst/ui/note/types/` | Desktop video renderer | +| VoiceTrack | `amethyst/ui/note/types/` | Desktop audio player | + +### Desktop-Specific (new code) + +| Component | Location | Notes | +|-----------|----------|-------| +| DesktopImageLoader setup | `desktopApp/` or `commons/jvmMain/` | Coil3 disk cache config for desktop | +| DesktopVideoPlayer | `desktopApp/` | VLCJ wrapper composable | +| DesktopFilePicker | `desktopApp/` | JFileChooser / AWT FileDialog | +| DragDropHandler | `desktopApp/` | Compose Desktop DnD API | +| ClipboardPasteHandler | `desktopApp/` | AWT clipboard image reading | +| DesktopMediaCompressor | `commons/jvmMain/` | Java ImageIO-based compression | +| DesktopBlurhashCalculator | `commons/jvmMain/` | BufferedImage → blurhash | +| DesktopExifStripper | `commons/jvmMain/` | metadata-extractor (lossless EXIF removal) | +| MediaScreen (desktop) | `desktopApp/` | Desktop media gallery/feed layout | +| UploadDialog (desktop) | `desktopApp/` | Upload progress, server selection, alt text | + +## Blossom Protocol Implementation + +### Upload Flow (BUD-02) + +``` +1. User drops/pastes/picks file +2. Client computes SHA-256 locally +3. (Optional) HEAD /upload pre-flight check (BUD-06) +4. Sign kind 24242 auth event (BUD-11) with t=upload +5. PUT /upload with binary body + Authorization header +6. Server returns BlobDescriptor {url, sha256, size, type, uploaded} +7. (Optional) PUT /media for server-side optimization (BUD-05) +8. (Optional) PUT /mirror to additional servers (BUD-04) +9. Client adds imeta tag to note event (NIP-92) +``` + +### Server Discovery (BUD-03) + +``` +1. Query user's kind 10063 event from relays +2. Parse server URLs from ["server", "https://..."] tags +3. Cache server list +4. Upload to preferred server(s) +5. When URL breaks: extract SHA-256 from URL → try other servers +``` + +### Auth (BUD-11) + +```kotlin +// Kind 24242 event structure +BlossomAuthorizationEvent( + content = "Upload Blob", + tags = [ + ["t", "upload"], + ["x", ""], // scope to specific blob + ["expiration", ""], // required + ["server", "cdn.example.com"] // scope to server + ] +) +// Sent as: Authorization: Nostr +``` + +## NIP Coverage + +| NIP | What | How We Use It | +|-----|------|---------------| +| NIP-92 | Media Attachments (imeta tag) | Attach metadata to media URLs in notes | +| NIP-94 | File Metadata (kind 1063) | File header events, metadata tags | +| NIP-B7 | Blossom Media | Server discovery, URL fallback | +| NIP-68 | Picture Events (kind 20) | Picture-first posts, profile gallery | +| NIP-71 | Video Events (kind 21/22) | Video display and metadata | +| NIP-17 | Private DMs (encrypted files) | Encrypted media in DMs | + +## Desktop UX Patterns + +### Drag & Drop +- Drop zone on compose area +- Visual feedback (border highlight, preview) +- Multiple files → MultiOrchestrator +- Accept: images, videos, audio files + +### Clipboard Paste (Ctrl+V) +- Detect image data in clipboard +- Auto-create temp file → upload flow +- Screenshot workflow: PrtScn → paste → upload + +### File Picker +- OS-native dialog (JFileChooser on desktop) +- Filter by supported media types +- Multiple file selection + +### Lightbox +- Click image → full-screen overlay +- Mouse wheel zoom + pan +- Arrow keys for gallery navigation +- Esc to close +- Save to disk option + +### Upload Progress +- Inline progress indicator per file +- Server selection dropdown (from kind 10063 list) +- Alt text input field +- Compression toggle +- Preview before send + +## Phases + +### Phase 1: Image Display Foundation +**Goal:** Images render in desktop notes with blurhash previews. + +| Task | Module | Details | +|------|--------|---------| +| Desktop Coil3 ImageLoader setup | `desktopApp/` | DiskCache (OS-appropriate path), MemoryCache, OkHttp network, custom fetchers | +| Extract BlossomFetcher | `amethyst/` → `commons/` | Coil3 fetcher for `blossom:` URIs | +| Extract BlurHashFetcher | `amethyst/` → `commons/` | Coil3 fetcher for blurhash placeholder rendering | +| Extract Base64Fetcher | `amethyst/` → `commons/` | Coil3 fetcher for base64 data URIs | +| Desktop inline image rendering | `desktopApp/` | AsyncImage in note content, aspect ratio handling | +| MediaAspectRatioCache extraction | `amethyst/` → `commons/` | LruCache for URL→aspect ratio (replace Android LruCache with common impl) | + +**Deliverable:** Notes in desktop feed display inline images with blurhash placeholders. +**Verifiable:** Run desktop app, navigate to feed with image posts, images load with blue/gray previews → full images. + +### Phase 2: Blossom Upload Protocol Extraction +**Goal:** Shared upload client in commons, usable by Android and Desktop. + +| Task | Module | Details | +|------|--------|---------| +| Create `BlossomClient` | `commons/commonMain/` | HTTP PUT /upload, /mirror, /media. Takes InputStream. Returns BlobDescriptor. | +| Create `BlossomAuthHelper` | `commons/commonMain/` | Kind 24242 event creation + base64 encoding | +| Create `BlossomServerDiscovery` | `commons/commonMain/` | Kind 10063 query, server list resolution | +| Create expect/actual file operations | `commons/` | `readFileBytes`, `computeFileSha256`, `getMimeType`, `getFileSize` | +| Create expect/actual `MediaMetadataExtractor` | `commons/` | Dimensions, blurhash computation | +| Create expect/actual `MediaCompressor` | `commons/` | JPEG quality, EXIF stripping (metadata-extractor) | +| Extract `UploadOrchestrator` | `amethyst/` → `commons/` | Multi-server coordination using BlossomClient | +| Extract `MultiUploadOrchestrator` | `amethyst/` → `commons/` | Parallel file upload management | +| Extract `MediaUploadResult` | `amethyst/` → `commons/` | Already platform-agnostic | +| Migrate Android to use commons upload | `amethyst/` | Android actuals + wire up to existing UploadOrchestrator callers | +| Extract `BlossomServersViewModel` | `amethyst/` → `commons/` | Server list state management | + +**Deliverable:** `./gradlew :commons:jvmTest` passes with upload unit tests. Android still works. +**Verifiable:** Android upload flow unchanged. Desktop can call BlossomClient to upload a file. + +### Phase 3: Desktop Upload UX +**Goal:** Upload media from desktop via file picker, drag-drop, and clipboard paste. + +| Task | Module | Details | +|------|--------|---------| +| Desktop file picker | `desktopApp/` | JFileChooser with media type filters, multi-select | +| Drag-and-drop handler | `desktopApp/` | `dragAndDropTarget` + `awtTransferable` + `javaFileListFlavor` | +| Clipboard paste handler | `desktopApp/` | AWT Toolkit clipboard, `DataFlavor.imageFlavor`, temp file creation | +| Upload dialog composable | `desktopApp/` | Progress bar, server selector (kind 10063), alt text field, compression toggle | +| Upload preview | `desktopApp/` or `commons/` | Thumbnail preview before upload | +| Wire up to compose screen | `desktopApp/` | Add media button to note composer, connect upload flow | + +**Deliverable:** Desktop user can drag image → see preview → upload to Blossom → post note with imeta. +**Verifiable:** Drop file on compose area, see upload progress, note publishes with embedded image. + +### Phase 4: Video Playback +**Goal:** Videos play inline in desktop notes. + +| Task | Module | Details | +|------|--------|---------| +| Add VLCJ + vlc-setup plugin | `desktopApp/build.gradle.kts` | `ir.mahozad.vlc-setup`, VLCJ 4.8.x dep | +| DesktopVideoPlayer composable | `desktopApp/` | SwingPanel + EmbeddedMediaPlayerComponent | +| Video controls overlay | `desktopApp/` | Play/pause, seek bar, volume, fullscreen toggle | +| Video in note rendering | `desktopApp/` | Replace URL-only display with inline player | +| Video upload support | `desktopApp/` | Accept video files in upload flow (Phase 3) | + +**Deliverable:** Video posts play inline in desktop feed. +**Verifiable:** Navigate to note with mp4/webm URL, video plays with controls. + +### Phase 5: Lightbox & Gallery +**Goal:** Full-screen media viewing with zoom and gallery navigation. + +| Task | Module | Details | +|------|--------|---------| +| Lightbox overlay composable | `desktopApp/` or `commons/` | Full-screen overlay, semi-transparent backdrop | +| Zoom + pan | `desktopApp/` | Mouse wheel zoom, click-drag pan (use zoomable lib or custom) | +| Gallery carousel | `commons/commonMain/` | Multi-image navigation (arrow keys + swipe indicators) | +| Save to disk | `desktopApp/` | Right-click or button → save image/video to local filesystem | +| Keyboard shortcuts | `desktopApp/` | Esc close, Left/Right navigate, +/- zoom | + +**Deliverable:** Click any image → fullscreen lightbox with zoom, multi-image gallery navigation. +**Verifiable:** Click image in note, zooms to fullscreen. Arrow keys cycle images. Esc closes. + +### Phase 6: Encrypted Media (DM Files) +**Goal:** Send and receive encrypted files in NIP-17 DMs. + +| Task | Module | Details | +|------|--------|---------| +| Extract encryption logic | `amethyst/` → `commons/` | NostrCipher usage for file encrypt/decrypt | +| Desktop encrypted upload flow | `desktopApp/` | Pick file → encrypt → Blossom upload → send encrypted event | +| Desktop encrypted display | `desktopApp/` | Receive encrypted file event → download → decrypt → display | +| Chat file upload dialog | `desktopApp/` | Similar to Phase 3 upload dialog but in DM context | + +**Deliverable:** Desktop DM users can send/receive encrypted images and files. +**Verifiable:** Send image in DM from desktop, receive on Android (and vice versa). + +### Phase 7: Profile Gallery (NIP-68) +**Goal:** View and create picture-first posts (kind 20). Profile gallery tab. + +| Task | Module | Details | +|------|--------|---------| +| Picture event display | `desktopApp/` | Kind 20 renderer with image-first layout | +| Profile gallery tab | `desktopApp/` | Grid of user's picture posts | +| Picture post composer | `desktopApp/` | Create kind 20 events with multiple images + imeta | +| Gallery entry events | `desktopApp/` | ProfileGalleryEntryEvent support | + +**Deliverable:** Desktop profile shows gallery tab. Users can create Instagram-style picture posts. +**Verifiable:** View profile → gallery tab shows image grid. Create picture post → visible on Android. + +### Phase 8: Media Server Management +**Goal:** UI for managing Blossom server list (kind 10063). + +| Task | Module | Details | +|------|--------|---------| +| Server list settings screen | `desktopApp/` | View/add/remove/reorder Blossom servers | +| Server status checking | `commons/` | HEAD request to verify server availability | +| Default server selection | `desktopApp/` | Choose preferred upload server | +| Publish kind 10063 | `commons/` | Update server list on relays | + +**Deliverable:** Desktop settings page to manage Blossom servers. +**Verifiable:** Add server → appears in upload dialog dropdown. Remove server → no longer used. + +### Phase 9: Audio Playback +**Goal:** Play audio tracks (MP3, OGG, FLAC) in notes. + +| Task | Module | Details | +|------|--------|---------| +| Audio player composable | `desktopApp/` | VLCJ audio-only mode (no video surface needed) | +| Waveform visualization | `desktopApp/` | Optional: visual waveform for voice messages | +| Audio in note rendering | `desktopApp/` | Play/pause button + progress bar inline | + +**Deliverable:** Audio files play inline in notes. +**Verifiable:** Note with MP3 URL shows audio player, plays on click. + +### Phase Dependency Graph + +``` +Phase 1 (Images) ─────┬──→ Phase 5 (Lightbox) + │ +Phase 2 (Upload) ──────┼──→ Phase 3 (Desktop UX) ──→ Phase 6 (Encrypted) + │ + ├──→ Phase 7 (Gallery) + │ + └──→ Phase 8 (Server Mgmt) + +Phase 4 (Video) ────────────→ Phase 9 (Audio) + +Independent: Phase 1, 2, 4 can run in parallel +``` + +## Assumptions + +1. **Coil3 JVM/Desktop is production-ready** — Coil3 3.x advertises Compose Multiplatform support. Verify actual JVM desktop stability before committing. +2. **VLCJ + Compose SwingPanel works** — SwingPanel embeds Swing components in Compose. VLCJ renders to a Canvas/Panel. Need spike to confirm smooth integration (no flickering, proper resizing). +3. **OkHttp in commons is fine** — BlossomUploader uses OkHttp for HTTP. Both Android and Desktop are JVM, so OkHttp works in `commons/jvmAndroid/` or `commons/commonMain/` (OkHttp has KMP support). If iOS is ever targeted, this becomes an issue. +4. **Extracting to commons won't break Android** — Moving upload code from `amethyst/` to `commons/` requires updating Android imports. Must ensure Android's Koin DI and lifecycle wiring still works. +5. **libvlc can be bundled per-platform** — Compose Desktop packaging plugin supports native lib bundling. Need to verify for macOS (dylib), Linux (.so), Windows (.dll). + +## Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| VLCJ SwingPanel flicker | Video unusable | Spike test early (Phase 4 is independent) | +| Commons extraction breaks Android | Regression | Run Android build after each extraction | +| Coil3 JVM disk cache bugs | Missing images | Fall back to OkHttp manual caching | +| libvlc bundle size (~100MB) | Large app | Consider optional download or separate installer | +| Scope creep (15 features) | Never ships | Phases exist for a reason — ship Phase 1-3 first | + +## Resolved Questions + +1. **Video player** — VLCJ with bundled libvlc. Ship libvlc with the app (~100MB) for zero user setup. Full format support (mp4, webm, m3u8, mkv, etc.). + +2. **EXIF stripping** — Use Drew Noakes' `metadata-extractor` library. Surgically strip EXIF while preserving image quality (no re-encoding loss). + +3. **Upload concurrency** — Higher parallelism than Android by default (desktop has more resources). Upload to multiple Blossom servers simultaneously. Make concurrent upload count user-configurable in settings. + +4. **Coil3 disk cache** — Use OS-appropriate paths: macOS `~/Library/Caches/AmethystDesktop`, Linux `$XDG_CACHE_HOME/AmethystDesktop` (default `~/.cache/`), Windows `%LOCALAPPDATA%/AmethystDesktop/cache`. Use `maxSizeBytes(1GB)` not `maxSizePercent` (that needs Android Context). + +5. **Compose Desktop DnD** — `Modifier.dragAndDropTarget` is experimental (`@ExperimentalFoundationApi`) in 1.7.x. Uses `event.awtTransferable` + `DataFlavor.javaFileListFlavor` on desktop. Old `onExternalDrag` deprecated, removed in 1.8.0. API works but expect minor changes. + +6. **Media compression** — JPEG: `ImageWriteParam.compressionQuality` (0.0-1.0). PNG: lossless deflate level. WebP: use `org.sejda.imageio:webp-imageio` for lossy/lossless write (~3MB native libs per platform). Start with JPEG/PNG re-encoding, add WebP output later. + +7. **libvlc bundling** — Use `ir.mahozad.vlc-setup` Gradle plugin. Downloads and bundles libvlc per platform. Targets VLC 3.x + VLCJ 4.8.x. Compose Desktop integration via `SwingPanel`. + +## Open Questions + +1. **vlc-setup Apple Silicon** — Does the vlc-setup plugin support arm64 macOS, or only x86_64? Need to verify. +2. **Compose DnD macOS quirks** — Does `awtTransferable` properly deliver file URIs on macOS, or are there Finder-specific issues? diff --git a/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md b/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md new file mode 100644 index 000000000..c87f757e2 --- /dev/null +++ b/docs/plans/2026-03-16-desktop-media-manual-testing-plan.md @@ -0,0 +1,167 @@ +# Manual Testing Plan: Desktop Media Full Parity (`feat/desktop-media`) + +## Context +Branch has 13 commits implementing Phases 0-9 of desktop media: image display, upload, video/audio playback, lightbox, encrypted DM files, profile gallery, server management, and imeta tags. 49 unit tests cover service logic. This plan covers **integration & UI manual testing** that unit tests can't reach. + +## Prerequisites +- VLC installed on system (required for VLCJ video/audio) +- Logged into a Nostr account with signing capability +- At least one reachable Blossom server (e.g. `https://blossom.primal.net`) +- Test files ready: JPEG (with EXIF), PNG, GIF, SVG, MP4, MP3, large file (>10MB) + +## Run Command +```bash +./gradlew :desktopApp:run +``` + +--- + +## Phase 1: Image Display & Caching + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 1.1 | Images load in feed | Open feed with image notes | Blurhash placeholder shows first, then full image fades in | ⬜ TODO | +| 1.2 | User avatars render | Browse feed/profile | All profile pictures display correctly | ⬜ TODO | +| 1.3 | Animated GIF | Open note with GIF URL | GIF animates with all frames | ✅ PASS (was static-only, fixed with AnimatedGifImage composable) | +| 1.4 | Base64 inline images | Open note with base64 data URI | Image decodes and displays | ⬜ TODO | +| 1.5 | Cache persistence | Close app, reopen, revisit same feed | Previously loaded images appear instantly (disk cache) | ⬜ TODO | +| 1.6 | Cache location | Check OS cache dir | macOS: `~/Library/Caches/amethyst/`, Linux: `~/.cache/amethyst/` | ⬜ TODO | +| 1.7 | Broken image URL | Note with 404 image URL | Graceful fallback (no crash, placeholder or blank) | ⬜ TODO | + +--- + +## Phase 2: File Upload (Blossom Protocol) — ⏭️ SKIPPED (come back later) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 2.1 | Upload JPEG | Compose note → attach JPEG → publish | Upload succeeds, URL in note | ⬜ TODO | +| 2.2 | Upload PNG | Compose note → attach PNG → publish | Upload succeeds, URL in note | ⬜ TODO | +| 2.3 | EXIF stripped | Upload JPEG with GPS EXIF → download result from Blossom URL | No EXIF metadata in downloaded file | ⬜ TODO | +| 2.4 | Auth header | Upload with Nostr signer | Server accepts upload (BUD-11 auth works) | ⬜ TODO | +| 2.5 | Upload progress | Attach large file → upload | Progress indicator updates during upload | ⬜ TODO | +| 2.6 | Upload error | Disconnect network mid-upload | Error state shown, no crash | ⬜ TODO | +| 2.7 | Server selector | Open compose → check server dropdown | Shows configured Blossom servers | ⬜ TODO | + +--- + +## Phase 3: Upload UX (File Picker, Paste, Drag-Drop) — ⏭️ SKIPPED (come back later) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 3.1 | File picker | Compose → click Attach → select image | Native dialog opens, file appears in attachment row | ⬜ TODO | +| 3.2 | Multi-select | File picker → select 3 images | All 3 appear in attachment row with thumbnails | ⬜ TODO | +| 3.3 | File type filter | File picker dialog | Only media files shown (images, video, audio) | ⬜ TODO | +| 3.4 | Clipboard paste | Copy image → Compose → Cmd+V/Ctrl+V | Pasted image appears in attachment row | ⬜ TODO | +| 3.5 | Drag & drop | Drag image file onto compose dialog | File appears in attachment row; visual drop indicator | ⬜ TODO | +| 3.6 | Remove attachment | Click X on attached file | File removed from row | ⬜ TODO | +| 3.7 | Thumbnail preview | Attach image | Thumbnail visible in attachment row | ⬜ TODO | +| 3.8 | Alt text | Attach image → enter alt text → publish | Alt text included in imeta tag | ⬜ TODO | +| 3.9 | Imeta tags | Publish note with image | Published event has imeta tag (url, m, x, dim, blurhash) | ⬜ TODO | + +--- + +## Phase 4: Video Playback (VLCJ) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 4.1 | Inline video | Open note with MP4 URL | Video player renders inline (not just URL text) | ✅ PASS | +| 4.2 | Play/pause | Click play button | Video plays; click again pauses | ✅ PASS | +| 4.3 | Seek bar | Drag seek bar | Video jumps to position | ✅ PASS | +| 4.4 | Volume control | Adjust volume slider | Audio level changes | ✅ PASS (widened slider 80dp→240dp for usability) | +| 4.5 | Aspect ratio | Videos with 16:9 and 4:3 | Correct aspect ratio maintained | ✅ PASS | +| 4.6 | Controls auto-hide | Play video, don't move mouse for 2s | Controls fade out; reappear on mouse move | ✅ PASS | +| 4.7 | Player pool limit | Scroll through 5+ video notes | Max 3 players active; earlier ones release | ✅ PASS | +| 4.8 | WebM format | Note with WebM URL | Plays correctly (if VLC supports it) | ✅ PASS | + +--- + +## Phase 5: Lightbox & Gallery Navigation + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 5.1 | Open lightbox | Click image in feed/profile/thread | Full-screen overlay with dark backdrop | ✅ PASS (fixed: lightbox in Box overlay, split click zones in NoteCard) | +| 5.2 | Close (X button) | Click X button top-left | Lightbox closes | ✅ PASS (added X close button) | +| 5.3 | Close (Esc) | Press Escape | Lightbox closes | ✅ PASS | +| 5.4 | Zoom (scroll) | Mouse wheel up/down | Image zooms in/out (up to 10x) | ✅ PASS | +| 5.5 | Pan (drag) | Zoom in → click-drag | Image pans with cursor | ✅ PASS | +| 5.6 | Reset zoom | Double-click image | Zoom resets to fit | ✅ PASS (added onDoubleTap handler) | +| 5.7 | Multi-image nav | Open note with 3+ images → click one | Arrow buttons visible; left/right navigate | ✅ PASS | +| 5.8 | Arrow key nav | Left/Right arrow keys | Navigate between images | ✅ PASS | +| 5.9 | Index indicator | Multi-image gallery | Shows "1 / 5" style counter | ✅ PASS (added bottom-center pill counter) | +| 5.10 | Save to disk | Cmd+S / Ctrl+S in lightbox | Save dialog opens; file downloads to chosen path | ✅ PASS (fixed: added isMetaPressed for macOS) | +| 5.11 | Video in lightbox | Click video thumbnail | Video plays in lightbox with controls | ✅ PASS | + +--- + +## Phase 6: Encrypted Media (NIP-17 DMs) — ⏭️ SKIPPED (DM file attach not implemented yet — MessageInput is text-only) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 6.1 | DM file attach | Open DM → attach file | Encryption indicator visible | ⬜ BLOCKED — no attach button in DM input | +| 6.2 | Send encrypted | Attach file in DM → send | File uploads encrypted to Blossom | ⬜ BLOCKED | +| 6.3 | Receive encrypted | Receive DM with encrypted file | File downloads and decrypts; displays correctly | ⬜ TODO | +| 6.4 | Wrong key | (If testable) Attempt to view another user's encrypted media | Decryption fails gracefully | ⬜ TODO | + +--- + +## Phase 7: Profile Gallery (NIP-68 / Kind 20) + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 7.1 | Gallery tab visible | Navigate to profile | "Gallery" tab appears | ✅ PASS | +| 7.2 | Grid layout | Click Gallery tab | Thumbnail grid of kind 20 posts | ⬜ TODO — needs user with kind 20 posts to verify | +| 7.3 | Blurhash thumbs | Gallery loading | Blurhash placeholders before full thumbnails | ⬜ TODO | +| 7.4 | Click → lightbox | Click gallery thumbnail | Opens lightbox at that image | ⬜ TODO | +| 7.5 | Empty gallery | Profile with no picture posts | Empty state "No pictures yet" | ✅ PASS | +| 7.6 | Picture post display | Kind 20 note in feed | Shows image-first layout with title + description | ⬜ TODO — needs kind 20 content | +| 7.7 | Create picture post | Compose kind 20 with multiple images | Multi-image post publishes with imeta tags | ⬜ BLOCKED — no kind 20 compose UI | + +--- + +## Phase 8: Blossom Server Management + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 8.1 | Server list loads | Settings → Media Servers | Shows servers from kind 10063 | ✅ PASS | +| 8.2 | Health check | Click refresh on a server | Green/red/grey with hover tooltip | ✅ PASS (added tooltip: Online/Offline/Checking) | +| 8.3 | Check all | Click "Check All" | All servers checked in parallel | ✅ PASS | +| 8.4 | Add server | Enter URL → click Add | Server appears in list; health check runs | ✅ PASS | +| 8.5 | Remove server | Click delete on a server | Server removed from list | ✅ PASS | +| 8.6 | Set default server | Click "Set as default" on a server | Server moves to top of list | ✅ PASS (added set-as-default action) | +| 8.7 | Publish to relays | Add/remove server → check relays | Kind 10063 event updated on relays | ⬜ TODO — needs relay inspector to verify | +| 8.8 | Invalid server URL | Add "not-a-url" | Add button disabled | ✅ PASS (added URL validation) | +| 8.9 | Settings scroll | Scroll settings page | Content scrolls | ✅ PASS (fixed: added verticalScroll) | + +--- + +## Phase 9: Audio Playback + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 9.1 | Inline audio | Note with MP3 URL | Audio player renders inline | | +| 9.2 | Play/pause | Click play | Audio plays; click again pauses | | +| 9.3 | Seek | Drag seek bar | Playback jumps to position | | +| 9.4 | Time display | Play audio file | Shows current/total time | | +| 9.5 | Multiple formats | Notes with OGG, WAV, FLAC, AAC, OPUS, M4A | All play (where VLC supports) | | +| 9.6 | Audio pool | Scroll past 6+ audio notes | Max 5 audio players; earlier ones release | | + +--- + +## Phase 10: Cross-Cutting / Edge Cases + +| # | Test | Steps | Expected | Status | +|---|------|-------|----------|--------| +| 10.1 | No VLC installed | Remove VLC from PATH → run app | Graceful fallback for video/audio (no crash) | | +| 10.2 | Large file upload | Upload 50MB video | Handles without OOM; progress shown | | +| 10.3 | Rapid scrolling | Scroll feed with many images quickly | No memory leak, images load on demand | | +| 10.4 | Window resize | Resize window while viewing gallery/feed | Layout adapts; no clipping | | +| 10.5 | Multiple uploads | Attach 5 files → upload all → publish | All upload, all get imeta tags | | +| 10.6 | App restart | Restart app after uploads/config | Cache, server prefs, all persisted | | + +--- + +## Unanswered Questions +- Is VLCJ arm64 macOS working? (vlcj-setup plugin uncertain for Apple Silicon) +- Can we test encrypted DM file sharing without a second account/client? +- Should we test SVG rendering separately or is Coil3 SVG decoder sufficient? +- How to verify kind 10063 publish without a relay inspector tool? diff --git a/docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md b/docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md new file mode 100644 index 000000000..e7e02c445 --- /dev/null +++ b/docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md @@ -0,0 +1,833 @@ +--- +title: "feat: Desktop Media — Full Parity" +type: feat +status: active +date: 2026-03-16 +origin: docs/brainstorms/2026-03-16-desktop-media-brainstorm.md +deepened: 2026-03-16 +--- + +# Desktop Media — Full Parity + +## Enhancement Summary + +**Deepened on:** 2026-03-16 +**Research agents used:** Architecture Strategist, Performance Oracle, Security Sentinel, Code Simplicity Reviewer, Pattern Recognition Specialist, Race Condition Reviewer, Coil3 Best Practices Researcher, VLCJ Framework Docs Researcher, Blossom Protocol Researcher, Compose Desktop DnD/Clipboard Researcher, KMP Expect/Actual Pattern Analyzer + +### Critical Corrections (from original plan) + +| # | Original Assumption | Correction | +|---|-------------------|------------| +| 1 | `metadata-extractor` for EXIF stripping | **READ-ONLY library.** Use Apache Commons Imaging `ExifRewriter.removeExifMetadata()` for lossless JPEG EXIF removal | +| 2 | `LinkedHashMap.removeEldestEntry` for caches | **NOT thread-safe** — `get()` in access-order mode mutates internal linked list. Use existing `androidx.collection.LruCache` (already KMP dep in commons) or `ConcurrentHashMap` | +| 3 | `coil-gif` works on JVM desktop | **Android-only** — `AnimatedImageDecoder` requires Android API 28+. Use `org.jetbrains.skia.Codec` for GIF decoding (first frame or animated) | +| 4 | VLCJ via `SwingPanel` | **Flickering confirmed.** Use DirectRendering via `CallbackVideoSurface` → Skia Bitmap → Compose `Image` (no SwingPanel needed) | +| 5 | Missing `kotlinx-coroutines-swing` dep | Required for `Dispatchers.Main.immediate` on JVM desktop with Coil3 | +| 6 | BlossomFetcher "just move" to commons | **Not trivial** — depends on `BlossomServerResolver` which uses Android `LruCache` + `IRoleBasedHttpClientBuilder`. Must abstract resolver interface first | +| 7 | UploadOrchestrator "extract" to commons | Really a **REWRITE** — deeply coupled to `Context`, `Uri`, `R.string`, `Account`, `ServerType` enum with NIP-95/96/Blossom paths | +| 8 | `vlc-setup` plugin ready for production | **No confirmed arm64 macOS support.** Only tested on Intel Mac (High Sierra). macOS support marked "experimental" | +| 9 | Coil3 memory cache "just works" | **Hard-codes 512MB total memory on non-Android.** Must set `maxSizeBytes()` explicitly using `Runtime.getRuntime().maxMemory()` | +| 10 | `MediaUploadResult` has no platform deps | **Hidden dependency** on `BlurhashWrapper` which imports from Android-only `BlurHashFetcher.kt` | + +### Key Improvements from Research + +1. **VLCJ DirectRendering pattern** — Complete working code from ComposeVideoPlayer project (no SwingPanel) +2. **Coil3 JVM cookbook** — `PlatformContext.INSTANCE`, explicit cache sizing, stable Keyer for custom fetchers +3. **Blossom full spec** — BUD-01 through BUD-11 documented with HTTP examples and auth flow +4. **Compose DnD modern API** — `Modifier.dragAndDropTarget` (old `onExternalDrag` removed in 1.8), `text/uri-list` fallback for Linux +5. **Race condition mitigations** — 13 identified (3 CRITICAL), with concrete fixes +6. **Simplicity alternative** — Desktop-only code first, extract to commons later (0 users → ship fast) + +--- + +## Overview + +Add complete media functionality to Amethyst Desktop: image display, video playback, Blossom upload, drag-drop/paste UX, lightbox, gallery, encrypted DM media, profile gallery, server management, and audio playback. Currently desktop renders **zero media** — notes show URL text only, no inline images or video. + +## Problem Statement + +Desktop Amethyst is text-only. The `NoteCard` at `desktopApp/ui/note/NoteCard.kt:128-132` renders URLs as blue underlined text via `RichTextContent`. No `AsyncImage`, no Coil dependency, no ImageLoader setup. `UserAvatar` uses `coil3.compose.AsyncImage` from commons but likely fails silently — no `SingletonImageLoader` configured for desktop. + +Android has 40+ media-related files across upload, display, playback, and gallery. All are tightly coupled to Android APIs (`Context`, `Uri`, `ContentResolver`, `BitmapFactory`, `MediaMetadataRetriever`, `ExoPlayer`). + +## Proposed Solution + +Extract platform-agnostic media logic to commons using a 4-layer architecture, then build desktop-specific UI on top. Blossom-only upload (no NIP-96). Coil3 for images (already KMP). VLCJ with DirectRendering for video. + +(see brainstorm: `docs/brainstorms/2026-03-16-desktop-media-brainstorm.md`) + +### Simplicity Consideration + +The simplicity reviewer recommends a **desktop-only-first** approach: write desktop code in `desktopApp/` without extracting to commons. Extract later when Android migration happens. Rationale: desktop has zero users, so ship fast and iterate. This plan documents the full extraction architecture but **implementation should start desktop-only** for Phases 0-3, deferring commons extraction to a follow-up PR. + +## Technical Approach + +### Architecture: 4-Layer Extraction + +``` +Layer 1: commons/commonMain — Pure protocol (BlossomClient, auth, server discovery) +Layer 2: commons/commonMain — expect/actual file operations +Layer 3: commons/{androidMain,jvmMain} — Platform actuals +Layer 4: desktopApp/ and amethyst/ — Platform UI +``` + +### Research Insight: jvmAndroid Source Set + +The codebase already has a `jvmAndroid` intermediate source set in `commons/build.gradle.kts` that bridges Android and Desktop JVM code. OkHttp-based HTTP clients work identically on both — place shared JVM code there, not in `commonMain` (which would try to compile for iOS/web targets). + +### Key Architectural Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Upload protocol | Blossom only | NIP-96 deprecated (see brainstorm) | +| Image loading | Coil3 3.4.0 | Already in project, KMP support | +| Video playback | VLCJ 4.8.x + DirectRendering | SwingPanel flickers; DirectRendering uses CallbackVideoSurface → Skia Bitmap | +| Upload code | Desktop-only first, extract later | Ship fast, 0 desktop users, extract when Android migrates | +| Desktop input | Drag-drop + paste + file picker | Essential desktop UX | +| EXIF stripping | Apache Commons Imaging | `metadata-extractor` is read-only; Commons Imaging does lossless EXIF removal | +| Video bundling | Require system VLC for now | `vlc-setup` plugin has no confirmed arm64 macOS support | +| GIF decoding | `org.jetbrains.skia.Codec` | `coil-gif` is Android-only; Skia Codec decodes frames | +| Cache implementation | `androidx.collection.LruCache` | Already KMP dep in commons; thread-safe unlike `LinkedHashMap` | + +### Existing Codebase Inventory + +**Already shared (ready to use):** + +| Component | Location | +|-----------|----------| +| BlurHashDecoder/Encoder | `commons/blurhash/` | +| PlatformImage expect/actual | `commons/blurhash/PlatformImage.kt` → `.android.kt` / `.jvm.kt` | +| Base64ImagePlatform | `commons/base64Image/` (JVM uses ImageIO) | +| RichTextParser (URL → media type) | `commons/richtext/RichTextParser.kt` | +| MediaContentModels | `commons/richtext/MediaContentModels.kt` | +| UserAvatar (AsyncImage) | `commons/ui/components/UserAvatar.kt` | +| GalleryParser | `commons/richtext/GalleryParser.kt` | +| Blossom protocol events | `quartz/nipB7Blossom/` (kind 24242, 10063, URI, UploadResult) | +| NIP-94 FileHeader | `quartz/nip94FileMetadata/` | +| NIP-92 IMetaTag | `quartz/nip92IMeta/` | +| NIP-68 PictureEvent | `quartz/nip68Picture/` | +| NIP-71 VideoEvent/VideoMeta | `quartz/nip71Video/` | +| ProfileGalleryEntryEvent | `quartz/experimental/profileGallery/` | +| `androidx.collection.LruCache` | Already in commons dependencies (KMP) | + +**Android-only (needs extraction or desktop equivalent):** + +| Component | File | Android Deps | Action | +|-----------|------|-------------|--------| +| BlossomUploader | `amethyst/service/uploads/blossom/BlossomUploader.kt` | ContentResolver, Uri, Context, MimeTypeMap | **Rewrite** HTTP core for desktop | +| UploadOrchestrator | `amethyst/service/uploads/UploadOrchestrator.kt` | Context, Uri, R.string, Account, ServerType | **Rewrite** — too coupled for extraction | +| MultiOrchestrator | `amethyst/service/uploads/MultiOrchestrator.kt` | Context | Defer | +| MediaUploadResult | `amethyst/service/uploads/MediaUploadResult.kt` | Hidden BlurhashWrapper dep | Fix dep chain first | +| MediaCompressor | `amethyst/service/uploads/MediaCompressor.kt` | Context, Bitmap, Uri, Compressor | expect/actual | +| BlurhashMetadataCalculator | `amethyst/service/uploads/BlurhashMetadataCalculator.kt` | Context, BitmapFactory, MediaMetadataRetriever | expect/actual | +| BlossomServerResolver | `amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt` | LruCache (Android), IRoleBasedHttpClientBuilder | Replace LruCache with `androidx.collection.LruCache` (KMP) | +| ServerHeadCache | `amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt` | None | Move (safe) | +| ImageLoaderSetup | `amethyst/service/images/ImageLoaderSetup.kt` | Android SingletonImageLoader, GIF decoder (API 28+) | Desktop equivalent | +| ImageCacheFactory | `amethyst/service/images/ImageCacheFactory.kt` | Context for safeCacheDir | Desktop paths | +| BlossomFetcher | `amethyst/service/images/BlossomFetcher.kt` | Depends on BlossomServerResolver (Android LruCache) | Abstract resolver interface first | +| BlurHashFetcher | `amethyst/service/images/BlurHashFetcher.kt` | toAndroidBitmap() | JVM: toBufferedImage() | +| Base64Fetcher | `amethyst/service/images/Base64Fetcher.kt` | toBitmap() | JVM: toBufferedImage() | +| ZoomableContentView | `amethyst/ui/components/ZoomableContentView.kt` | Android zoom lib | Desktop equivalent | +| ImageGallery | `amethyst/ui/components/ImageGallery.kt` | Pure Compose | Extract | +| MediaAspectRatioCache | `amethyst/model/MediaAspectRatioCache.kt` | Android LruCache | `androidx.collection.LruCache` (KMP) | + +### Dependency Changes + +**desktopApp/build.gradle.kts — add:** +```kotlin +// Image loading (Coil3) +implementation(libs.coil.compose) +implementation(libs.coil.okhttp) +implementation(libs.coil.svg) +// NOTE: coil-gif is Android-only, skip it. Use Skia Codec instead. + +// Coroutines — required for Coil3 Main dispatcher on JVM desktop +implementation(libs.kotlinx.coroutines.swing) + +// Video playback (VLCJ) +implementation("uk.co.caprica:vlcj:4.8.3") + +// EXIF stripping (lossless) +implementation("org.apache.commons:commons-imaging:1.0.0-alpha5") +``` + +**gradle/libs.versions.toml — add:** +```toml +vlcj = "4.8.3" +commons-imaging = "1.0.0-alpha5" +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +``` + +### Research Insight: Coil3 JVM Desktop Configuration + +```kotlin +// Critical: PlatformContext.INSTANCE (not Android Context) +// Critical: Set memory cache explicitly (Coil3 hard-codes 512MB total on non-Android) +// Critical: Set disk cache to OS-appropriate persistent path (default is temp dir) + +fun createDesktopImageLoader(): ImageLoader { + return ImageLoader.Builder(PlatformContext.INSTANCE) + .memoryCache { + MemoryCache.Builder() + .maxSizeBytes(calculateDesktopMemoryCacheSize()) + .strongReferencesEnabled(true) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(getDesktopCacheDir().resolve("amethyst/image_cache").toOkioPath()) + .maxSizeBytes(512L * 1024 * 1024) // 512 MB + .build() + } + .precision(Precision.INEXACT) + .crossfade(true) + .components { + add(SvgDecoder.Factory()) // Works on JVM via Skia SVG + // add(SkiaGifDecoder.Factory()) // Custom: first-frame GIF via Codec + // add(BlossomFetcher.Factory(...)) + } + .build() +} + +fun calculateDesktopMemoryCacheSize(): Long { + val maxMemory = Runtime.getRuntime().maxMemory() + return (maxMemory * 0.25).toLong().coerceAtMost(512L * 1024 * 1024) +} + +fun getDesktopCacheDir(): File { + val os = System.getProperty("os.name").lowercase() + return when { + "mac" in os -> File(System.getProperty("user.home"), "Library/Caches") + "win" in os -> File(System.getenv("LOCALAPPDATA") ?: System.getProperty("user.home")) + else -> File(System.getenv("XDG_CACHE_HOME") ?: "${System.getProperty("user.home")}/.cache") + } +} +``` + +### Research Insight: Skia GIF Decoding (replaces coil-gif) + +```kotlin +// org.jetbrains.skia.Codec provides frame-by-frame GIF decoding +// Skia supports: PNG, JPEG, WebP (static), BMP, ICO, WBMP +// GIF: first frame via Image.makeFromEncoded, full animation via Codec +// Animated WebP: first frame only (same Codec approach for animation) +// HEIC: NOT supported on JVM + +class SkiaGifDecoder(private val source: ImageSource) : Decoder { + override suspend fun decode(): DecodeResult { + val bytes = source.source().use { it.readByteArray() } + val data = org.jetbrains.skia.Data.makeFromBytes(bytes) + val codec = org.jetbrains.skia.Codec.makeFromData(data) + val bitmap = org.jetbrains.skia.Bitmap() + bitmap.allocN32Pixels(codec.width, codec.height) + codec.readPixels(bitmap, 0) // first frame + bitmap.setImmutable() + return DecodeResult(image = bitmap.asImage(), isSampled = false) + } + class Factory : Decoder.Factory { /* check mimeType == "image/gif" */ } +} +``` + +### Research Insight: VLCJ DirectRendering Pattern + +```kotlin +// NO SwingPanel — renders directly to Skia Bitmap → Compose Image +val callbackVideoSurface = CallbackVideoSurface( + object : BufferFormatCallback { + override fun getBufferFormat(w: Int, h: Int): BufferFormat { + info = ImageInfo.makeN32(w, h, ColorAlphaType.OPAQUE) + return RV32BufferFormat(w, h) + } + override fun allocatedBuffers(buffers: Array) { + byteArray = ByteArray(buffers[0].limit()) + } + }, + object : RenderCallback { + override fun display(mp: MediaPlayer, bufs: Array, fmt: BufferFormat?) { + bufs[0].get(byteArray); bufs[0].rewind() + val bmp = Bitmap(); bmp.allocPixels(info!!); bmp.installPixels(byteArray) + imageBitmap = bmp.asComposeImageBitmap() // triggers recomposition + } + }, + true, VideoSurfaceAdapters.getVideoSurfaceAdapter() +) +// Then display via: Image(bitmap = imageBitmap, ...) +``` + +**Performance:** 2 frame copies per display frame. ~8MB/frame at 1080p. Adequate for 1-2 players, CPU-intensive for more. New ByteArray per frame required (reusing crashes). + +### Research Insight: Compose Desktop Drag-and-Drop + +```kotlin +// Modern API (1.8+): Modifier.dragAndDropTarget +// Old onExternalDrag was REMOVED in 1.8 +// awtTransferable + DataFlavor.javaFileListFlavor for files +// text/uri-list fallback needed for Linux + browser drops +// Cannot inspect file types during drag hover — filter in onDrop + +val dropTarget = remember { + object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + val transferable = event.awtTransferable ?: return false + if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List + onFilesDropped(files) + return true + } + return false + } + } +} +``` + +**Clipboard:** Compose `ClipboardManager` is text-only. Use AWT `Toolkit.getDefaultToolkit().systemClipboard` with `DataFlavor.imageFlavor` for image paste, `DataFlavor.javaFileListFlavor` for file paste. + +--- + +## Implementation Phases + +### Phase 0: Spike — Coil3 + VLCJ Desktop Validation + +**Goal:** Confirm Coil3 loads images on JVM desktop with disk cache. Confirm VLCJ DirectRendering works in Compose. + +| # | Task | File | Details | +|---|------|------|---------| +| 0.1 | Coil3 spike | `desktopApp/spike/CoilSpike.kt` | `ImageLoader.Builder(PlatformContext.INSTANCE)` with DiskCache (persistent path), MemoryCache (explicit size), load HTTP image via AsyncImage | +| 0.2 | VLCJ spike | `desktopApp/spike/VlcjSpike.kt` | DirectRendering via `CallbackVideoSurface` → Skia Bitmap → Compose `Image`. Test mp4 URL. **NO SwingPanel** | +| 0.3 | Validate SVG | spike | Test `coil-svg` + `SvgDecoder.Factory()` — confirmed KMP-compatible via Skia SVG | +| 0.4 | Validate GIF (first frame) | spike | Test `Image.makeFromEncoded(bytes)` for static GIF. Optionally test `Codec` for frame count | +| 0.5 | Add `kotlinx-coroutines-swing` | `desktopApp/build.gradle.kts` | Required for `Dispatchers.Main.immediate` | + +**Gate:** If VLCJ DirectRendering drops frames badly or Coil3 disk cache fails, evaluate alternatives (Klarity for video, OkHttp cache interceptor for images). +**Deliverable:** Spike branch with working video + image rendering. +**Effort:** 1-2 days. + +### Research Insight: Spike Simplification + +The simplicity reviewer notes Phase 0 can be a 20-line test in `main()` — no need for separate spike files. Just add deps, create `ImageLoader`, call `AsyncImage` in the existing desktop window. + +--- + +### Phase 1: Image Display Foundation + +**Goal:** Images render inline in desktop notes with blurhash previews. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 1.1 | Add Coil3 deps to desktopApp | `desktopApp/build.gradle.kts` | `coil-compose`, `coil-okhttp`, `coil-svg`, `kotlinx-coroutines-swing`. **NOT** `coil-gif` (Android-only) | +| 1.2 | Create DesktopImageLoaderSetup | `desktopApp/service/images/DesktopImageLoaderSetup.kt` | `ImageLoader.Builder(PlatformContext.INSTANCE)` with explicit MemoryCache size (25% of JVM heap, max 512MB), DiskCache (OS-appropriate persistent path), OkHttp, SvgDecoder, custom fetchers. Package: `com.vitorpamplona.amethyst.desktop.service.images` | +| 1.3 | Create DesktopImageCacheFactory | `desktopApp/service/images/DesktopImageCacheFactory.kt` | macOS: `~/Library/Caches/AmethystDesktop`, Linux: `$XDG_CACHE_HOME/AmethystDesktop`, Windows: `%LOCALAPPDATA%/AmethystDesktop/cache`. `maxSizeBytes(512MB)` | +| 1.4 | Create desktop BlossomFetcher | `desktopApp/service/images/DesktopBlossomFetcher.kt` | **Desktop-only first** (don't extract to commons yet). Simplified version without `IRoleBasedHttpClientBuilder`. Uses single OkHttpClient for now. Resolves `blossom:` URIs via kind 10063 server list | +| 1.5 | Create desktop BlurHashFetcher | `desktopApp/service/images/DesktopBlurHashFetcher.kt` | Uses `PlatformImage.toBufferedImage()` → `BitmapImage(toComposeImageBitmap())`. Implement stable `Keyer` to avoid recomposition flicker | +| 1.6 | Create desktop Base64Fetcher | `desktopApp/service/images/DesktopBase64Fetcher.kt` | Uses existing `Base64ImagePlatform.jvm.kt` with ImageIO | +| 1.7 | Create MediaAspectRatioCache | `desktopApp/model/MediaAspectRatioCache.kt` | Use `androidx.collection.LruCache(1000)` (already KMP dep). **NOT** `LinkedHashMap` (not thread-safe) | +| 1.8 | Update NoteCard for inline images | `desktopApp/ui/note/NoteCard.kt` | Parse imeta tags from events. When URL matches image extension → `AsyncImage` with blurhash placeholder. Use `RichTextParser` media classification | +| 1.9 | Initialize ImageLoader in Main.kt | `desktopApp/Main.kt` | Call `setSingletonImageLoaderFactory` at app startup. Use `@OptIn(DelicateCoilApi::class)` for eager init | +| 1.10 | Add SkiaGifDecoder | `desktopApp/service/images/SkiaGifDecoder.kt` | Custom Coil3 `Decoder` using `org.jetbrains.skia.Codec` for GIF first-frame rendering | + +**Deliverable:** Notes with images show blurhash placeholder → full image. `UserAvatar` also starts working. + +**Acceptance Criteria:** +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] Desktop app shows inline images in feed notes +- [ ] Blurhash placeholders display while loading +- [ ] SVG images render correctly (via SvgDecoder) +- [ ] GIF shows first frame (static display is acceptable for MVP) +- [ ] `UserAvatar` renders profile pictures +- [ ] Disk cache persists across app restarts (not in temp dir) +- [ ] Android build unbroken: `./gradlew :amethyst:compileDebugKotlin` + +### Research Insight: Coil3 Custom Fetcher Gotchas + +- Use `coil3.Uri` not `android.net.Uri` +- `ConnectivityChecker` is a no-op on desktop (always returns "connected") +- Custom fetchers **must** implement stable `Keyer` or cache key — otherwise recomposition triggers re-fetch ([#2551](https://github.com/coil-kt/coil/issues/2551)) +- `PlatformContext.INSTANCE` is an empty singleton on JVM — don't cast or use Android methods + +--- + +### Phase 2: Desktop Blossom Upload Client + +**Goal:** Upload media from desktop to Blossom servers. + +**Approach:** Write a **desktop-only** upload client in `desktopApp/`. Don't extract to commons yet (simplicity-first). The Android upload path stays unchanged. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 2.1 | Create DesktopBlossomClient | `desktopApp/service/upload/DesktopBlossomClient.kt` | HTTP PUT `/upload`, `/mirror`, `/media`, DELETE. Takes `File` + metadata. Returns `BlossomUploadResult` (from quartz). Uses OkHttp. Package: `com.vitorpamplona.amethyst.desktop.service.upload` | +| 2.2 | Create DesktopBlossomAuthHelper | `desktopApp/service/upload/DesktopBlossomAuthHelper.kt` | Creates kind 24242 auth events via quartz `BlossomAuthorizationEvent`, base64-encodes for `Authorization: Nostr ` header. Include `t`, `x`, `expiration`, `server` tags per BUD-11 | +| 2.3 | Create DesktopServerDiscovery | `desktopApp/service/upload/DesktopServerDiscovery.kt` | Queries kind 10063 from relays, resolves server list, caches with `androidx.collection.LruCache` | +| 2.4 | Create DesktopMediaMetadata | `desktopApp/service/upload/DesktopMediaMetadata.kt` | `ImageIO.read(file)` for dimensions, `BlurHashEncoder` (from commons) for blurhash, file size + SHA-256 | +| 2.5 | Create DesktopMediaCompressor | `desktopApp/service/upload/DesktopMediaCompressor.kt` | JPEG: `ImageWriteParam.compressionQuality`. EXIF strip: Apache Commons Imaging `ExifRewriter.removeExifMetadata()`. No re-encoding for EXIF removal | +| 2.6 | Create DesktopUploadOrchestrator | `desktopApp/service/upload/DesktopUploadOrchestrator.kt` | Coordinate: strip EXIF → compute metadata → auth → upload. Accept `File` directly (not Uri). Blossom-only (no NIP-95/96) | +| 2.7 | Create DesktopMediaUploadTracker | `desktopApp/service/upload/DesktopMediaUploadTracker.kt` | Simple `mutableStateOf(isUploading: Boolean)` for MVP (matches existing Android's 47-line tracker). StateFlow upgrade later | +| 2.8 | BUD-06 preflight check | `desktopApp/service/upload/ServerHeadCache.kt` | HEAD `/upload` with `X-Content-Type`, `X-Content-Length`, `X-SHA-256` headers. Cache results | +| 2.9 | Unit tests | `desktopApp/src/jvmTest/` | Mock OkHttp responses. Test upload, auth header format (base64 of kind 24242), SHA-256 computation, EXIF stripping | + +**Deliverable:** `./gradlew :desktopApp:jvmTest` passes. Desktop can upload files to Blossom. + +**Acceptance Criteria:** +- [ ] BlossomClient uploads file to test server (integration test or mock) +- [ ] Auth headers correctly signed (kind 24242 with `t=upload`, `x=`, `expiration`, `server`) +- [ ] SHA-256 computed correctly for test files +- [ ] EXIF stripped losslessly from JPEG test image +- [ ] BUD-06 preflight HEAD works +- [ ] Android build unbroken + +### Research Insight: Blossom Protocol Details + +**Upload flow per BUD-02:** +1. Compute SHA-256 of exact file bytes +2. Create kind 24242 event: `t=upload`, `x=`, `expiration=+1hr`, `server=` +3. Base64-encode (standard base64 works despite spec saying base64url) +4. `PUT /upload` with `Authorization: Nostr `, `Content-Type`, `Content-Length`, `X-SHA-256` +5. Response: `BlobDescriptor` JSON with `url`, `sha256`, `size`, `type`, `uploaded` + +**`/upload` vs `/media` (BUD-05):** +- `/upload` — server MUST NOT modify blob. Hash preserved. +- `/media` — server MAY optimize (strip EXIF, compress). Returned hash differs. Primal uses this exclusively. +- **Recommendation:** Use `/upload` first (simpler). Add `/media` support later for trusted servers. + +**Auth token security (BUD-11):** +- Always include `server` tag to prevent token replay across servers +- Unscoped `delete` tokens can be replayed — always scope delete operations +- Include `x` tag (blob hash) for upload/delete operations + +### Research Insight: Race Conditions in Upload + +**CRITICAL:** Upload scope cancellation on navigation. If user navigates away during upload, the coroutine scope gets cancelled. Mitigation: launch uploads in a `GlobalScope` or `applicationScope` that survives navigation, with a reference in the tracker. + +**HIGH:** DnD during active upload. User drops new files while upload in progress. Mitigation: queue uploads, don't replace in-flight uploads. + +--- + +### Phase 3: Desktop Upload UX + +**Goal:** Upload media from desktop via file picker, drag-drop, and clipboard paste. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 3.1 | Desktop file picker | `desktopApp/ui/media/DesktopFilePicker.kt` | `java.awt.FileDialog` (native look) or `JFileChooser` with media type filters. Multi-select support | +| 3.2 | Drag-and-drop handler | `desktopApp/ui/media/DragDropHandler.kt` | `Modifier.dragAndDropTarget` (modern API, 1.8+). `event.awtTransferable` + `DataFlavor.javaFileListFlavor`. Include `text/uri-list` fallback for Linux. Visual drop zone indicator via `onEntered`/`onExited` callbacks | +| 3.3 | Clipboard paste handler | `desktopApp/ui/media/ClipboardPasteHandler.kt` | AWT `Toolkit.getDefaultToolkit().systemClipboard`. Check `DataFlavor.imageFlavor` (→ save BufferedImage to temp file → upload) and `DataFlavor.javaFileListFlavor` (→ direct file upload) | +| 3.4 | Upload dialog composable | `desktopApp/ui/media/UploadDialog.kt` | Progress bar per file, server selector (kind 10063 list), alt text input, cancel button | +| 3.5 | Upload preview thumbnails | `desktopApp/ui/media/UploadPreview.kt` | Thumbnail generation from local file using ImageIO | +| 3.6 | Wire upload to ComposeNoteDialog | `desktopApp/ui/ComposeNoteDialog.kt` | Add "Attach media" button. Connect to file picker + drag-drop. On upload complete → insert imeta tag + URL into note | +| 3.7 | Media button in note composer | `desktopApp/ui/ComposeNoteDialog.kt` | IconButton (Attach) → opens file picker. Shows attached files with remove option | + +**Deliverable:** Drop/paste/pick file → preview → upload → note publishes with embedded image. + +**Acceptance Criteria:** +- [ ] File picker opens, filters by media type, multi-select works +- [ ] Drag file onto compose area shows drop zone highlight +- [ ] Clipboard paste (Ctrl+V / Cmd+V) detects images and files +- [ ] Upload progress shows per-file +- [ ] Server selection from kind 10063 list +- [ ] Alt text saved in imeta tag +- [ ] Published note contains correct imeta tags +- [ ] Cancel upload works mid-flight + +### Research Insight: DnD Platform Quirks + +| Platform | Behavior | Note | +|----------|----------|------| +| macOS Finder | `javaFileListFlavor` works | Aliases resolve to alias file (use `canonicalFile`) | +| Windows Explorer | `javaFileListFlavor` works | Stable | +| Linux Nautilus | `javaFileListFlavor` works | Some WMs only provide `text/uri-list` | +| Browser drag | Usually `text/uri-list` | Not `javaFileListFlavor` — handle separately | +| **Cannot inspect file types during drag hover** | Only know flavor (files vs text), not extensions | Filter in `onDrop`, show generic "drop files here" during hover | + +--- + +### Phase 4: Video Playback (VLCJ DirectRendering) + +**Goal:** Videos play inline in desktop notes. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 4.1 | Add VLCJ deps | `desktopApp/build.gradle.kts` | `uk.co.caprica:vlcj:4.8.3`. **No vlc-setup plugin** — require system VLC for now (arm64 macOS not confirmed for plugin) | +| 4.2 | DesktopVideoPlayer composable | `desktopApp/ui/media/DesktopVideoPlayer.kt` | DirectRendering: `CallbackVideoSurface` → `RV32BufferFormat` → `ByteBuffer.get(byteArray)` → `Skia Bitmap.installPixels()` → `asComposeImageBitmap()` → Compose `Image`. Lazy init player | +| 4.3 | Video controls overlay | `desktopApp/ui/media/VideoControls.kt` | Play/pause, seek bar, volume slider, fullscreen toggle, time display. Auto-hide after 2s. Compose overlay on top of Compose Image (no z-order issues since no SwingPanel) | +| 4.4 | Video in note rendering | `desktopApp/ui/note/NoteCard.kt` | When URL matches video extension → show thumbnail + play button. On click → load VLCJ player | +| 4.5 | Video upload support | `desktopApp/ui/media/UploadDialog.kt` | Accept video files in upload flow. No transcoding — upload as-is | +| 4.6 | Video thumbnail generation | `desktopApp/service/media/VideoThumbnailGenerator.kt` | Use VLCJ `snapshots().get(320, 0)` on a dedicated player. Play → seek to 2s → pause → snapshot → stop | +| 4.7 | VLCJ player pool | `desktopApp/service/media/VlcjPlayerPool.kt` | Single `MediaPlayerFactory`, pool of 2-3 reusable `EmbeddedMediaPlayer` instances. Reuse players (`media().play(newMrl)`) instead of create/destroy. Strong references to prevent GC crash | + +**Deliverable:** Video posts play inline. Controls work. Resource-efficient. + +**Acceptance Criteria:** +- [ ] mp4, webm URLs play inline (m3u8 if VLC supports) +- [ ] Play/pause, seek, volume controls functional +- [ ] Fullscreen toggle works +- [ ] Video pauses when scrolled out of view +- [ ] Player instances pooled (max 3 active) +- [ ] Graceful fallback if VLC init fails (show URL link) +- [ ] VLC not installed → show "Install VLC" prompt with download link + +### Research Insight: VLCJ Lifecycle Critical Rules + +1. **Never let player instances get garbage collected** — native callbacks crash JVM if Java object is collected +2. **Keep strong references** to `MediaPlayerFactory`, `EmbeddedMediaPlayer`, and video surfaces +3. **Reuse players** — one factory, pool of players. Change media with `media().play(newMrl)` +4. **Release order:** player first, then factory +5. **macOS: only CallbackVideoSurface works** — no heavyweight AWT components since Java 7 +6. **Audio-only:** Use `MediaPlayerFactory("--no-video")` for audio tracks + +### Research Insight: Race Conditions in Video + +**CRITICAL:** VLCJ use-after-release segfault. If `DisposableEffect.onDispose` releases player while a `RenderCallback.display()` is executing on the VLC thread → native crash. Mitigation: state machine per player slot. Set `RELEASING` state, wait for in-flight render to complete, then release. + +**CRITICAL:** Rapid navigation between notes with video. User scrolls fast → player allocated → scrolled away before `mediaPlayerReady` event → player released during initialization. Mitigation: debounce player allocation (300ms visibility threshold before allocating). + +--- + +### Phase 5: Lightbox & Gallery + +**Goal:** Full-screen media viewing with zoom and gallery navigation. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 5.1 | Lightbox overlay | `desktopApp/ui/media/LightboxOverlay.kt` | Full-window overlay with semi-transparent backdrop. Click outside to close | +| 5.2 | Zoom + pan | `desktopApp/ui/media/ZoomableImage.kt` | Mouse wheel zoom, click-drag pan. `graphicsLayer` with `scaleX/Y` + `translationX/Y`. Double-click to reset | +| 5.3 | Gallery carousel | `desktopApp/ui/media/GalleryCarousel.kt` | Multi-image navigation with left/right buttons and indicator dots | +| 5.4 | Save to disk | `desktopApp/ui/media/SaveMediaAction.kt` | Button → `JFileChooser` save dialog. Download URL → write to chosen path | +| 5.5 | Keyboard shortcuts | `desktopApp/ui/media/LightboxOverlay.kt` | Esc=close, Left/Right=navigate, +/-=zoom, Ctrl+S=save, Space=play/pause (video) | +| 5.6 | Extract ImageGallery layout logic | `amethyst/ui/components/ImageGallery.kt` → `commons/` | The 1-5+ image grid layout is pure Compose — extract to commons for reuse | + +**Deliverable:** Click image → fullscreen lightbox with zoom. Multi-image carousel. Keyboard navigation. + +**Acceptance Criteria:** +- [ ] Click any inline image opens lightbox +- [ ] Mouse wheel zooms in/out smoothly +- [ ] Click-drag pans when zoomed +- [ ] Arrow keys navigate gallery +- [ ] Esc closes lightbox +- [ ] Save downloads file to disk +- [ ] Video plays in lightbox with controls + +### Research Insight: Race Condition — Lightbox Double-Click + +**MEDIUM:** User double-clicks an image. First click opens lightbox, second click registers as "click outside" → closes immediately. Mitigation: 200ms debounce on lightbox open/close transitions. + +--- + +### Phase 6: Encrypted Media (NIP-17 DM Files) + +**Goal:** Send and receive encrypted files in DMs. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 6.1 | Verify encryption is shared | `quartz/nip17Dm/files/` | `ChatMessageEncryptedFileHeaderEvent` (kind 15) already in commonMain with AESGCM cipher from quartz utils. Should work on desktop as-is | +| 6.2 | Encrypted upload flow | `desktopApp/ui/chat/` | Pick file → encrypt with NIP-44 → Blossom upload → create ChatMessageEncryptedFileHeaderEvent | +| 6.3 | Encrypted download + display | `desktopApp/ui/chat/` | Receive encrypted file event → download from Blossom → decrypt → display/save | +| 6.4 | Chat file upload UI | `desktopApp/ui/chat/ChatFileUploader.kt` | Similar to Phase 3 upload dialog but in DM context. Show encryption indicator | + +**Deliverable:** Desktop DM users can send/receive encrypted images and files. + +**Acceptance Criteria:** +- [ ] Send image in DM from desktop, visible on Android +- [ ] Receive encrypted image from Android, displays on desktop +- [ ] Non-participants cannot view encrypted media +- [ ] Upload progress shown in chat compose + +--- + +### Phase 7: Profile Gallery (NIP-68) + +**Goal:** View and create picture-first posts (kind 20). Profile gallery tab. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 7.1 | Picture event display | `desktopApp/ui/note/PictureDisplay.kt` | Kind 20 renderer. Image-first layout with title + description below. Uses imeta tags for multi-image | +| 7.2 | Profile gallery tab | `desktopApp/ui/profile/GalleryTab.kt` | Grid layout of user's picture posts. Lazy grid with thumbnails | +| 7.3 | Picture post composer | `desktopApp/ui/media/PicturePostComposer.kt` | Create kind 20 events. Multi-image upload + imeta + title + description | +| 7.4 | Gallery entry event support | `desktopApp/ui/profile/GalleryTab.kt` | Read `ProfileGalleryEntryEvent` (kind 1163) for curated galleries | + +**Deliverable:** Profile shows gallery tab with image grid. Users can create picture posts. + +**Acceptance Criteria:** +- [ ] Profile screen shows Gallery tab +- [ ] Gallery grid loads thumbnails with blurhash +- [ ] Click thumbnail opens lightbox +- [ ] Can create kind 20 picture post from desktop +- [ ] Picture post visible on Android Amethyst + +--- + +### Phase 8: Media Server Management + +**Goal:** UI for managing Blossom server list (kind 10063). + +| # | Task | Module | Details | +|---|------|--------|---------| +| 8.1 | Server list settings screen | `desktopApp/ui/settings/MediaServerSettings.kt` | View/add/remove servers. Drag to reorder | +| 8.2 | Server status checking | `desktopApp/service/media/ServerHealthCheck.kt` | HEAD request to verify availability. Show green/red indicator | +| 8.3 | Default server selection | settings UI | Mark preferred upload server. Persist in Preferences | +| 8.4 | Publish kind 10063 | Uses quartz `BlossomServersEvent` | Update on relays when user modifies list | + +**Deliverable:** Settings page to manage Blossom servers. + +**Acceptance Criteria:** +- [ ] Server list loads from kind 10063 event +- [ ] Add/remove servers updates list +- [ ] Health check shows server status +- [ ] Changes published to relays +- [ ] Upload dialog reflects server list + +--- + +### Phase 9: Audio Playback + +**Goal:** Play audio tracks (MP3, OGG, FLAC, WAV) inline in notes. + +| # | Task | Module | Details | +|---|------|--------|---------| +| 9.1 | Audio player composable | `desktopApp/ui/media/AudioPlayer.kt` | VLCJ with `MediaPlayerFactory("--no-video")`. Play/pause + progress bar + time. No video surface needed | +| 9.2 | Audio in note rendering | `desktopApp/ui/note/NoteCard.kt` | URL matches audio extension → show inline audio player | +| 9.3 | Waveform visualization (optional) | `desktopApp/ui/media/AudioWaveform.kt` | Simple amplitude bars. Low priority — skip if VLCJ doesn't expose PCM | + +**Deliverable:** Audio files play inline in notes. + +**Acceptance Criteria:** +- [ ] MP3, OGG, FLAC URLs show audio player +- [ ] Play/pause and seek work +- [ ] Multiple audio players on screen don't conflict + +### Phase Dependency Graph + +``` +Phase 0 (Spike) ──→ Phase 1 (Images) ─────┬──→ Phase 5 (Lightbox) + │ + Phase 2 (Upload) ──────┼──→ Phase 3 (Desktop UX) ──→ Phase 6 (Encrypted) + │ + ├──→ Phase 7 (Gallery) + │ + └──→ Phase 8 (Server Mgmt) + + Phase 4 (Video) ────────────→ Phase 9 (Audio) + +Independent: Phase 0 first. Then Phase 1, 2, 4 can run in parallel. +Ship Phases 0-3 as first PR. Phases 4-9 are incremental follow-ups. +``` + +## System-Wide Impact + +### Interaction Graph + +- Image display: NoteCard → RichTextParser → MediaContentModels → Coil3 ImageLoader → custom Fetchers → OkHttp → Blossom servers +- Upload: ComposeNoteDialog → DesktopFilePicker/DragDrop/Clipboard → DesktopMediaCompressor → DesktopBlossomClient → OkHttp → Blossom server → imeta tag → relay broadcast +- Auth chain: DesktopUploadOrchestrator → DesktopBlossomAuthHelper → quartz BlossomAuthorizationEvent → Account.signer → kind 24242 → base64 header + +### Error & Failure Propagation + +| Error Source | Handling | +|-------------|----------| +| Coil3 load failure | Show blurhash if available, else placeholder icon. Log error | +| Blossom upload network error | Retry with exponential backoff (3 attempts). Show error in upload dialog | +| VLCJ init failure (VLC not installed) | Show "Install VLC" prompt with download link. Fall back to URL link | +| SHA-256 mismatch after upload | Re-upload. Warn user if persistent | +| Server unreachable (BUD-06 preflight) | Skip server, try next in list | +| Encrypted media decrypt failure | Show "Unable to decrypt" placeholder | +| EXIF strip failure | Upload anyway with warning (privacy risk) | + +### Race Condition Mitigations (from review) + +| Severity | Issue | Mitigation | +|----------|-------|------------| +| **CRITICAL** | LinkedHashMap concurrent corruption | Use `androidx.collection.LruCache` (thread-safe) or `ConcurrentHashMap` | +| **CRITICAL** | VLCJ use-after-release segfault | State machine per player: `IDLE`→`LOADING`→`PLAYING`→`RELEASING`. Guard `RenderCallback.display()` with state check | +| **CRITICAL** | Upload scope cancelled on navigation | Launch uploads in application-scoped coroutine (survives navigation) | +| HIGH | Stale blurhash/image flash in lazy list | Stable `Keyer` for Coil3 custom fetchers | +| HIGH | DnD during active upload | Queue uploads, don't replace in-flight | +| HIGH | Non-atomic progress + state update | Use `MutableStateFlow` (single source of truth, atomic update) | +| HIGH | Pause-after-dispose (VLCJ) | Check player state before calling `controls().pause()` | +| MEDIUM | Stale server cache | TTL on server discovery cache (5 min) | +| MEDIUM | Lightbox double-click | 200ms debounce on open/close | +| MEDIUM | Gallery rapid navigation | Debounce image load requests | +| MEDIUM | StateFlow progress frequency | Throttle upload progress updates to 100ms intervals | +| LOW | Clipboard + DnD concurrent | Serialize input handling (queue) | +| LOW | Video control timer overlap | Single timer job for auto-hide | + +### State Lifecycle Risks + +| Risk | Mitigation | +|------|------------| +| Upload in progress + user navigates away | Application-scoped coroutine. Upload continues in background. Show notification on completion | +| VLCJ player leak | `DisposableEffect` with state machine release. Player pool enforces max count. Strong references prevent GC crash | +| Coil disk cache corruption | Coil3 handles internally. Worst case: re-download | +| Partial upload (network cut) | SHA-256 pre-computed. Server rejects incomplete uploads. Client retries | + +### API Surface Parity + +| Interface | Android | Desktop | Same? | +|-----------|---------|---------|-------| +| BlossomClient | Android-specific | Desktop-specific | **Different** (for now — extract to commons later) | +| UploadOrchestrator | Android-specific | Desktop-specific | **Different** (for now) | +| ImageLoader fetchers | Android-specific | Desktop-specific | **Different** (for now — same Coil3 API though) | +| VideoPlayer | ExoPlayer | VLCJ DirectRendering | Different | +| FilePicker | ActivityResultContracts | JFileChooser / FileDialog | Different | +| MediaCompressor | Zelory + MediaCodec | ImageIO + Commons Imaging | Different | + +### Integration Test Scenarios + +1. **Upload round-trip:** Desktop uploads image → published note with imeta → other client sees image via Blossom URL +2. **Cross-platform encrypted DM:** Android sends encrypted image → Desktop receives, decrypts, displays +3. **Blossom fallback:** Primary server down → resolver tries secondary server from kind 10063 +4. **Gallery consistency:** Create kind 20 on desktop → visible in Android profile gallery +5. **Video lifecycle:** Scroll feed with 5 video notes → only 2-3 VLCJ players active → no OOM + +## Alternative Approaches Considered + +| Approach | Why Rejected | +|----------|-------------| +| NIP-96 + Blossom | NIP-96 deprecated. Double protocol = double maintenance (see brainstorm) | +| JavaFX MediaView for video | Adds JavaFX runtime (~50MB), conflicts with Compose Desktop rendering | +| SwingPanel for VLCJ | Confirmed flickering + always-on-top z-order issues. DirectRendering avoids entirely | +| Extract to commons immediately | Over-engineering for 0 desktop users. Ship desktop-only first, extract when needed | +| FFmpeg JNI for video | Complex native binding. VLCJ wraps VLC which bundles FFmpeg internally | +| Ktor instead of OkHttp | OkHttp already used everywhere. Both are JVM. No benefit to switching | +| `metadata-extractor` for EXIF strip | **Read-only library**. Only reads EXIF, cannot remove it | +| `LinkedHashMap` for caches | **Not thread-safe** in access-order mode. `get()` mutates internal state | +| Klarity (FFmpeg-based) | Interesting alternative (65 stars, Feb 2026 update), but small community. Worth prototyping as VLCJ backup | + +## Risk Analysis & Mitigation + +| Risk | Probability | Impact | Mitigation | +|------|------------|--------|------------| +| VLCJ DirectRendering CPU overhead at 1080p | Medium | Medium | ~8MB/frame copy. Adequate for 1-2 players. Pool limits exposure | +| VLC not installed on user system | High | High | Show clear "Install VLC" prompt. Link to VLC download. Future: bundle VLC | +| vlc-setup plugin no arm64 macOS | Confirmed | Medium | Don't use plugin for now. Require system VLC. Revisit when plugin matures | +| Coil3 JVM edge cases | Low | Medium | Coil 3.4.0 is mature. Explicit cache sizing avoids defaults. SvgDecoder confirmed working | +| Commons extraction breaks Android | N/A (deferred) | N/A | Desktop-only approach for Phases 0-3 eliminates this risk | +| Apache Commons Imaging alpha status | Medium | Low | Only using EXIF strip (well-tested path). Fallback: skip EXIF strip for MVP | +| Scope creep across 9 phases | High | High | Ship Phases 0-3 as first PR. Later phases are incremental | +| Compose DnD experimental API changes | Medium | Low | Pin Compose version. AWT `DropTarget` fallback available | + +## Acceptance Criteria + +### Functional Requirements + +- [ ] Images display inline in desktop feed notes with blurhash placeholders +- [ ] Upload works via file picker, drag-drop, and clipboard paste +- [ ] Videos play inline with controls (play/pause, seek, volume) +- [ ] Lightbox opens on image click with zoom and gallery navigation +- [ ] Encrypted media works in DMs (send + receive) +- [ ] Profile gallery shows picture posts +- [ ] Blossom server management UI in settings +- [ ] Audio plays inline in notes + +### Non-Functional Requirements + +- [ ] Image load time < 2s for typical photos on broadband +- [ ] Upload shows progress in real-time +- [ ] Max 3 active video players (prevent OOM) +- [ ] Disk cache respects 512MB limit +- [ ] EXIF stripped before upload (privacy) +- [ ] Android build never broken (desktop-only approach) + +### Quality Gates + +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] `./gradlew :desktopApp:jvmTest` passes (upload unit tests) +- [ ] `./gradlew :amethyst:compileDebugKotlin` passes (no Android breakage) +- [ ] `./gradlew spotlessApply` passes +- [ ] Manual test: full upload → display round-trip on desktop + +## Key Files (New or Modified) + +### New Files + +| File | Phase | +|------|-------| +| `desktopApp/.../service/images/DesktopImageLoaderSetup.kt` | 1 | +| `desktopApp/.../service/images/DesktopImageCacheFactory.kt` | 1 | +| `desktopApp/.../service/images/DesktopBlossomFetcher.kt` | 1 | +| `desktopApp/.../service/images/DesktopBlurHashFetcher.kt` | 1 | +| `desktopApp/.../service/images/DesktopBase64Fetcher.kt` | 1 | +| `desktopApp/.../service/images/SkiaGifDecoder.kt` | 1 | +| `desktopApp/.../model/MediaAspectRatioCache.kt` | 1 | +| `desktopApp/.../service/upload/DesktopBlossomClient.kt` | 2 | +| `desktopApp/.../service/upload/DesktopBlossomAuthHelper.kt` | 2 | +| `desktopApp/.../service/upload/DesktopServerDiscovery.kt` | 2 | +| `desktopApp/.../service/upload/DesktopMediaMetadata.kt` | 2 | +| `desktopApp/.../service/upload/DesktopMediaCompressor.kt` | 2 | +| `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` | 2 | +| `desktopApp/.../service/upload/DesktopMediaUploadTracker.kt` | 2 | +| `desktopApp/.../ui/media/DesktopFilePicker.kt` | 3 | +| `desktopApp/.../ui/media/DragDropHandler.kt` | 3 | +| `desktopApp/.../ui/media/ClipboardPasteHandler.kt` | 3 | +| `desktopApp/.../ui/media/UploadDialog.kt` | 3 | +| `desktopApp/.../ui/media/UploadPreview.kt` | 3 | +| `desktopApp/.../ui/media/DesktopVideoPlayer.kt` | 4 | +| `desktopApp/.../ui/media/VideoControls.kt` | 4 | +| `desktopApp/.../service/media/VlcjPlayerPool.kt` | 4 | +| `desktopApp/.../service/media/VideoThumbnailGenerator.kt` | 4 | +| `desktopApp/.../ui/media/LightboxOverlay.kt` | 5 | +| `desktopApp/.../ui/media/ZoomableImage.kt` | 5 | +| `desktopApp/.../ui/media/GalleryCarousel.kt` | 5 | +| `desktopApp/.../ui/media/AudioPlayer.kt` | 9 | + +### Modified Files + +| File | Phase | Change | +|------|-------|--------| +| `desktopApp/build.gradle.kts` | 0,1,4 | Add Coil3, kotlinx-coroutines-swing, VLCJ, Commons Imaging deps | +| `gradle/libs.versions.toml` | 0,1,4 | Add new version entries | +| `desktopApp/ui/note/NoteCard.kt` | 1,4,9 | Add inline media rendering | +| `desktopApp/ui/ComposeNoteDialog.kt` | 3 | Add media attach button + upload flow | +| `desktopApp/Main.kt` | 1 | Initialize ImageLoader | + +## Sources & References + +### Origin + +- **Brainstorm document:** [docs/brainstorms/2026-03-16-desktop-media-brainstorm.md](docs/brainstorms/2026-03-16-desktop-media-brainstorm.md) +- Key decisions carried forward: Blossom-only, Coil3 for images, VLCJ for video, desktop-only-first approach + +### Internal References + +- Existing shared UI analysis: `docs/shared-ui-analysis.md` +- PlatformImage expect/actual pattern: `commons/blurhash/PlatformImage.kt` +- Android ImageLoader setup: `amethyst/service/images/ImageLoaderSetup.kt:25-91` +- Android BlossomUploader: `amethyst/service/uploads/blossom/BlossomUploader.kt` +- NoteCard (current text-only): `desktopApp/ui/note/NoteCard.kt:128-132` +- ComposeNoteDialog (current text-only): `desktopApp/ui/ComposeNoteDialog.kt` +- Blossom protocol research: `docs/brainstorms/2026-03-16-blossom-protocol-research.md` + +### External References + +- Blossom protocol spec: https://github.com/hzrd149/blossom +- Coil3 Compose Multiplatform: https://coil-kt.github.io/coil/compose/ +- Coil3 GIF on JVM limitation: https://github.com/coil-kt/coil/issues/2347 +- Coil3 SVG on desktop: https://github.com/coil-kt/coil/issues/2330 +- Coil3 Main dispatcher: https://github.com/coil-kt/coil/issues/2009 +- VLCJ documentation: https://github.com/caprica/vlcj +- VLCJ GC tutorial: https://capricasoftware.co.uk/tutorials/vlcj/4/garbage-collection +- ComposeVideoPlayer (DirectRendering): https://github.com/rjuszczyk/ComposeVideoPlayer +- Klarity (FFmpeg alternative): https://github.com/numq/Klarity +- vlc-setup Gradle plugin: https://github.com/nickolay-mahozad/vlc-setup +- Apache Commons Imaging: https://commons.apache.org/proper/commons-imaging/ +- Compose Desktop DnD docs: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-drag-drop.html +- Skiko Codec API: https://jetbrains.github.io/skiko/skiko/org.jetbrains.skia/-codec/index.html +- HEIC not supported on JVM: https://github.com/JetBrains/skiko/issues/942 + +## Answered Questions (from original plan) + +| # | Question | Answer | +|---|----------|--------| +| 1 | Does `coil3-gif` work on JVM desktop? | **No.** Android-only (`AnimatedImageDecoder` requires API 28+). Use `org.jetbrains.skia.Codec` for GIF decoding | +| 2 | Does `vlc-setup` support arm64 macOS? | **Unconfirmed.** Only Intel Mac (High Sierra) tested. macOS marked "experimental". Require system VLC for now | +| 3 | Does `awtTransferable` deliver files on macOS? | **Yes.** `DataFlavor.javaFileListFlavor` works with Finder on JDK 17+. Include `text/uri-list` fallback for Linux | +| 4 | Is `LinkedHashMap.removeEldestEntry` sufficient? | **No.** Not thread-safe in access-order mode. Use `androidx.collection.LruCache` (already KMP dep) or `ConcurrentHashMap` | +| 5 | Is Coil3 `SvgDecoder` KMP? | **Yes.** Works on JVM via `org.jetbrains.skia.svg.SVGDOM`. Add `SvgDecoder.Factory()` to ImageLoader components | +| 6 | Is `UserAvatar` rendering on desktop? | **Likely failing silently** — no `SingletonImageLoader` configured. Will work after Phase 1 ImageLoader init | + +## Remaining Unanswered Questions + +1. Does VLCJ DirectRendering achieve acceptable frame rate at 1080p on Apple Silicon? +2. Can `org.jetbrains.skia.Codec` handle all animated WebP variants reliably on JVM? +3. Is VLC 3.0.21 universal binary reliable via JNA on arm64 macOS with JDK 17+? +4. Is there a GPU-accelerated path for VLCJ → Skia Bitmap transfer (avoid CPU copy)? +5. Does `Modifier.dragAndDropTarget` work on Linux Wayland in Compose 1.10.x? +6. Can Klarity handle streaming URLs (HLS, DASH) as VLCJ alternative? +7. Does Coil3 `@ExperimentalCoilApi` `NetworkFetcher` constructor stay stable across versions?