feat(media): global media player — persistent playback across navigation

Media playback now survives navigation. A GlobalMediaPlayer singleton owns
VLC players and exposes StateFlows. Composables are thin viewports.
NowPlayingBar has full controls (volume, mute, save, fullscreen).
GlobalFullscreenOverlay renders video fullscreen above all screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-18 12:27:13 +02:00
parent 6e1315a4fa
commit 2a6087af27
22 changed files with 3844 additions and 797 deletions
@@ -31,6 +31,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -146,7 +148,13 @@ sealed class DesktopScreen {
fun main() { fun main() {
DesktopImageLoaderSetup.setup() 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 // Pre-init VLC on background thread so first play is fast
Thread { VlcjPlayerPool.init() }.start() Thread { VlcjPlayerPool.init() }.start()
application { application {
@@ -687,61 +695,71 @@ fun MainContent(
val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
when (layoutMode) { Row(Modifier.fillMaxSize().weight(1f)) {
LayoutMode.SINGLE_PANE -> { when (layoutMode) {
SinglePaneLayout( LayoutMode.SINGLE_PANE -> {
relayManager = relayManager, SinglePaneLayout(
localCache = localCache, relayManager = relayManager,
accountManager = accountManager, localCache = localCache,
account = account, accountManager = accountManager,
nwcConnection = nwcConnection, account = account,
subscriptionsCoordinator = subscriptionsCoordinator, nwcConnection = nwcConnection,
appScope = appScope, subscriptionsCoordinator = subscriptionsCoordinator,
onShowComposeDialog = onShowComposeDialog, appScope = appScope,
onShowReplyDialog = onShowReplyDialog, onShowComposeDialog = onShowComposeDialog,
onZapFeedback = onZapFeedback, onShowReplyDialog = onShowReplyDialog,
signerConnectionState = signerConnectionState, onZapFeedback = onZapFeedback,
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)
}
},
signerConnectionState = signerConnectionState, signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec, lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.weight(1f),
) )
VerticalDivider()
} }
DeckLayout( LayoutMode.DECK -> {
deckState = deckState, if (!isImmersive) {
relayManager = relayManager, DeckSidebar(
localCache = localCache, onAddColumn = onShowAddColumnDialog,
accountManager = accountManager, onOpenSettings = {
account = account, if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
nwcConnection = nwcConnection, deckState.focusExistingColumn(DeckColumnType.Settings)
subscriptionsCoordinator = subscriptionsCoordinator, } else {
appScope = appScope, deckState.addColumn(DeckColumnType.Settings)
onShowComposeDialog = onShowComposeDialog, }
onShowReplyDialog = onShowReplyDialog, },
onZapFeedback = onZapFeedback, signerConnectionState = signerConnectionState,
modifier = Modifier.weight(1f), 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 // Snackbar for zap feedback
SnackbarHost( SnackbarHost(
@@ -804,7 +822,9 @@ fun RelaySettingsScreen(
accountManager.loadNwcConnection() accountManager.loadNwcConnection()
} }
Column(modifier = Modifier.fillMaxSize()) { Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
) {
Text( Text(
"Settings", "Settings",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
@@ -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<ImageBitmap?>(null)
val videoFrame: StateFlow<ImageBitmap?> = _videoFrame.asStateFlow()
private val _videoState = MutableStateFlow(MediaPlaybackState())
val videoState: StateFlow<MediaPlaybackState> = _videoState.asStateFlow()
// Audio state
private val _audioState = MutableStateFlow(MediaPlaybackState(type = MediaType.AUDIO))
val audioState: StateFlow<MediaPlaybackState> = _audioState.asStateFlow()
// Fullscreen
private val _isFullscreen = MutableStateFlow(false)
val isFullscreen: StateFlow<Boolean> = _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<out ByteBuffer>) {}
}
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) {
}
}
}
}
}
}
@@ -48,7 +48,7 @@ object VlcjPlayerPool {
// Video player pool (for actual playback) // Video player pool (for actual playback)
private val allPlayers = mutableListOf<EmbeddedMediaPlayer>() private val allPlayers = mutableListOf<EmbeddedMediaPlayer>()
private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>() private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
private const val MAX_POOL_SIZE = 4 private const val MAX_POOL_SIZE = 1
// Thumbnail player pool (separate so thumbnails don't compete with playback) // Thumbnail player pool (separate so thumbnails don't compete with playback)
private val allThumbPlayers = mutableListOf<EmbeddedMediaPlayer>() private val allThumbPlayers = mutableListOf<EmbeddedMediaPlayer>()
@@ -59,7 +59,7 @@ object VlcjPlayerPool {
private var audioFactory: MediaPlayerFactory? = null private var audioFactory: MediaPlayerFactory? = null
private val allAudioPlayers = mutableListOf<MediaPlayer>() private val allAudioPlayers = mutableListOf<MediaPlayer>()
private val idleAudioPlayers = ConcurrentLinkedQueue<MediaPlayer>() private val idleAudioPlayers = ConcurrentLinkedQueue<MediaPlayer>()
private const val MAX_AUDIO_POOL_SIZE = 5 private const val MAX_AUDIO_POOL_SIZE = 1
/** /**
* Initialize the pool. Thread-safe — only runs once. * Initialize the pool. Thread-safe — only runs once.
@@ -20,8 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.desktop.ui package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
@@ -122,15 +122,11 @@ fun FeedNoteCard(
) { ) {
val zapAmountSats = zapReceipts.sumOf { it.amountSats } val zapAmountSats = zapReceipts.sumOf { it.amountSats }
Column( Column {
modifier =
Modifier.clickable {
onNavigateToThread(event.id)
},
) {
NoteCard( NoteCard(
note = event.toNoteDisplayData(localCache), note = event.toNoteDisplayData(localCache),
localCache = localCache, localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile, onMentionClick = onNavigateToProfile,
onImageClick = onImageClick, onImageClick = onImageClick,
@@ -507,149 +503,156 @@ fun FeedScreen(
} }
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
Column(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Header with compose button — wraps on narrow columns Column(modifier = Modifier.fillMaxSize()) {
FlowRow( // Header with compose button — wraps on narrow columns
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), FlowRow(
horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.SpaceBetween,
) { verticalArrangement = Arrangement.spacedBy(8.dp),
Column { ) {
FlowRow( Column {
verticalArrangement = Arrangement.Center, FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.Center,
) { horizontalArrangement = Arrangement.spacedBy(8.dp),
Text( ) {
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", Text(
style = MaterialTheme.typography.headlineMedium, if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
color = MaterialTheme.colorScheme.onBackground, style = MaterialTheme.typography.headlineMedium,
) color = MaterialTheme.colorScheme.onBackground,
)
// Feed mode selector // Feed mode selector
if (account != null) { if (account != null) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip( FilterChip(
selected = feedMode == FeedMode.GLOBAL, selected = feedMode == FeedMode.GLOBAL,
onClick = { onClick = {
feedMode = FeedMode.GLOBAL feedMode = FeedMode.GLOBAL
DesktopPreferences.feedMode = FeedMode.GLOBAL DesktopPreferences.feedMode = FeedMode.GLOBAL
}, },
label = { Text("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, Spacer(Modifier.width(8.dp))
onClick = { IconButton(
feedMode = FeedMode.FOLLOWING onClick = { relayManager.connect() },
DesktopPreferences.feedMode = FeedMode.FOLLOWING modifier = Modifier.size(24.dp),
}, ) {
label = { Text("Following") }, Icon(
Icons.Default.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
) )
} }
} }
} }
Spacer(Modifier.height(4.dp)) // New Post button (primary action)
Row(verticalAlignment = Alignment.CenterVertically) { Button(
Text( onClick = onCompose,
"${connectedRelays.size} relays connected", enabled = account != null && !account.isReadOnly,
style = MaterialTheme.typography.bodyMedium, ) {
color = MaterialTheme.colorScheme.onSurfaceVariant, Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
)
if (feedMode == FeedMode.FOLLOWING) {
Text(
"${followedUsers.size} followed",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
IconButton( Text("New Post")
onClick = { relayManager.connect() }, }
modifier = Modifier.size(24.dp), }
) {
Icon( Spacer(Modifier.height(8.dp))
Icons.Default.Refresh,
contentDescription = "Refresh", if (connectedRelays.isEmpty()) {
tint = MaterialTheme.colorScheme.primary, LoadingState("Connecting to relays...")
modifier = Modifier.size(18.dp), } 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<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
) )
} }
} }
} }
} // end Column
// 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<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
)
}
}
}
// Reply dialog // Reply dialog
if (replyToEvent != null && account != null) { if (replyToEvent != null && account != null) {
@@ -671,5 +674,5 @@ fun FeedScreen(
onDismiss = { lightboxState = null }, onDismiss = { lightboxState = null },
) )
} }
} } // end Box
} }
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.createThreadRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription 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.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -137,6 +139,9 @@ fun ThreadScreen(
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) } var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) } var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
// Lightbox state
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
// Load metadata for thread authors + mentioned users via coordinator // Load metadata for thread authors + mentioned users via coordinator
LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) { LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null) { if (subscriptionsCoordinator != null) {
@@ -343,177 +348,200 @@ fun ThreadScreen(
return level return level
} }
Column(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Header with back button Column(modifier = Modifier.fillMaxSize()) {
Row( // Header with back button
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
) { verticalAlignment = Alignment.CenterVertically,
IconButton(onClick = onBack) { ) {
Icon( IconButton(onClick = onBack) {
Icons.AutoMirrored.Filled.ArrowBack, Icon(
contentDescription = "Back", Icons.AutoMirrored.Filled.ArrowBack,
modifier = Modifier.size(24.dp), 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()) { if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...") LoadingState("Connecting to relays...")
} else if (rootNote == null && !rootNoteEoseReceived) { } else if (rootNote == null && !rootNoteEoseReceived) {
LoadingState("Loading thread...") LoadingState("Loading thread...")
} else if (rootNote == null && rootNoteEoseReceived) { } else if (rootNote == null && rootNoteEoseReceived) {
EmptyState( EmptyState(
title = "Note not found", title = "Note not found",
description = "This note may have been deleted or is not available from connected relays", description = "This note may have been deleted or is not available from connected relays",
onRefresh = onBack, onRefresh = onBack,
refreshLabel = "Go back", refreshLabel = "Go back",
) )
} else { } else {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
) { ) {
// Root note (no reply level indicator) // Root note (no reply level indicator)
item(key = noteId) { item(key = noteId) {
Column( Column {
modifier = NoteCard(
Modifier.clickable { note = rootNote!!.toNoteDisplayData(localCache),
// 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,
localCache = localCache, localCache = localCache,
account = account, onAuthorClick = onNavigateToProfile,
nwcConnection = nwcConnection, onMentionClick = onNavigateToProfile,
onReplyClick = { onReply(rootNote!!) }, onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onZapFeedback = onZapFeedback, onMediaClick = { urls, index, seekPos ->
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
zapCount = rootZaps.size, .playVideo(urls[index], seekPos)
zapAmountSats = rootZaps.sumOf { it.amountSats }, com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
zapReceipts = rootZaps, .toggleFullscreen()
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<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
}, },
) )
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<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
)
}
} }
HorizontalDivider(thickness = 1.dp)
} }
HorizontalDivider(thickness = 1.dp)
}
// Reply notes with level indicators // Reply notes with level indicators
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event -> items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
val level = calculateLevel(event) val level = calculateLevel(event)
Column( Column(
modifier = modifier =
Modifier Modifier
.drawReplyLevel( .drawReplyLevel(
level = level, level = level,
color = MaterialTheme.colorScheme.outlineVariant, color = MaterialTheme.colorScheme.outlineVariant,
selected = selected =
if (event.id == noteId) { if (event.id == noteId) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.outlineVariant MaterialTheme.colorScheme.outlineVariant
}, },
).clickable { ).clickable {
onNavigateToThread(event.id) onNavigateToThread(event.id)
}, },
) { ) {
NoteCard( NoteCard(
note = event.toNoteDisplayData(localCache), note = event.toNoteDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
if (account != null) {
val eventZaps = zapsByEvent[event.id] ?: emptyList()
NoteActionsRow(
event = event,
relayManager = relayManager,
localCache = localCache, localCache = localCache,
account = account, onAuthorClick = onNavigateToProfile,
nwcConnection = nwcConnection, onMentionClick = onNavigateToProfile,
onReplyClick = { onReply(event) }, onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onZapFeedback = onZapFeedback, onMediaClick = { urls, index, seekPos ->
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
zapCount = eventZaps.size, .playVideo(urls[index], seekPos)
zapAmountSats = eventZaps.sumOf { it.amountSats }, com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
zapReceipts = eventZaps, .toggleFullscreen()
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<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
}, },
) )
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<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
)
}
} }
HorizontalDivider(thickness = 1.dp)
} }
HorizontalDivider(thickness = 1.dp)
}
// Empty state for no replies // Empty state for no replies
if (replyEvents.isEmpty() && repliesEoseReceived) { if (replyEvents.isEmpty() && repliesEoseReceived) {
item { item {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Text( Text(
"No replies yet", "No replies yet",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
) )
} }
} else if (replyEvents.isEmpty() && !repliesEoseReceived) { } else if (replyEvents.isEmpty() && !repliesEoseReceived) {
item { item {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Text( Text(
"Loading replies...", "Loading replies...",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp), 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
} }
/** /**
@@ -714,7 +714,10 @@ fun UserProfileScreen(
lightboxState = LightboxState(urls, index) lightboxState = LightboxState(urls, index)
}, },
onMediaClick = { urls, index, seekPos -> 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()
}, },
) )
} }
@@ -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<String?>(null)
val activeId: StateFlow<String?> = _activeId
fun activate(id: String) {
_activeId.value = id
}
fun deactivate(id: String) {
_activeId.compareAndSet(id, null)
}
}
@@ -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<ImageBitmap>,
val durations: List<Int>,
)
@Composable
fun AnimatedGifImage(
url: String,
modifier: Modifier = Modifier,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
) {
var gifFrames by remember(url) { mutableStateOf<GifFrames?>(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<ImageBitmap>(decodableFrames)
val durations = ArrayList<Int>(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
}
@@ -37,106 +37,26 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
import kotlinx.coroutines.delay
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
/**
* Audio-only player using VLCJ with pooled audio players.
* Uses VlcjPlayerPool's shared audio factory instead of creating one per instance.
*/
@Composable @Composable
fun AudioPlayer( fun AudioPlayer(
url: String, url: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var isPlaying by remember { mutableStateOf(false) } val audioState by GlobalMediaPlayer.audioState.collectAsState()
var position by remember { mutableFloatStateOf(0f) } val isActiveAudio = audioState.url == url
var duration by remember { mutableLongStateOf(0L) }
var currentTime by remember { mutableLongStateOf(0L) }
var vlcAvailable by remember { mutableStateOf(true) }
var player by remember { mutableStateOf<MediaPlayer?>(null) }
DisposableEffect(url) { val isPlaying = if (isActiveAudio) audioState.isPlaying else false
val mp = VlcjPlayerPool.acquireAudioPlayer() val position = if (isActiveAudio) audioState.position else 0f
if (mp == null) { val duration = if (isActiveAudio) audioState.duration else 0L
vlcAvailable = false val currentTime = if (isActiveAudio) audioState.currentTime else 0L
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
}
Row( Row(
modifier = modifier =
@@ -157,16 +77,10 @@ fun AudioPlayer(
IconButton( IconButton(
onClick = { onClick = {
player?.let { p -> if (isActiveAudio) {
if (isPlaying) { GlobalMediaPlayer.toggleAudioPlayPause()
p.controls().pause() } else {
} else { GlobalMediaPlayer.playAudio(url)
if (position <= 0f && !p.status().isPlaying) {
p.media().play(url)
} else {
p.controls().play()
}
}
} }
}, },
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
@@ -185,7 +99,7 @@ fun AudioPlayer(
Slider( Slider(
value = position, value = position,
onValueChange = { player?.controls()?.setPosition(it) }, onValueChange = { GlobalMediaPlayer.seekAudio(it) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -31,13 +31,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -45,27 +42,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp 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.VideoThumbnailCache
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay 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 @Composable
fun DesktopVideoPlayer( fun DesktopVideoPlayer(
@@ -78,29 +60,18 @@ fun DesktopVideoPlayer(
onViewModeChange: ((ViewMode) -> Unit)? = null, onViewModeChange: ((ViewMode) -> Unit)? = null,
trailingControls: @Composable (() -> Unit)? = null, trailingControls: @Composable (() -> Unit)? = null,
) { ) {
var frame by remember { mutableStateOf<ImageBitmap?>(null) } // Check if this URL is the active video
var isPlaying by remember { mutableStateOf(false) } val videoState by GlobalMediaPlayer.videoState.collectAsState()
var isBuffering by remember { mutableStateOf(false) } val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState()
var position by remember { mutableFloatStateOf(0f) } val isActiveVideo = videoState.url == url
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<EmbeddedMediaPlayer?>(null) }
var volume by remember { mutableIntStateOf(100) }
var isMuted by remember { mutableStateOf(false) }
// Unique ID for single-player enforcement // Thumbnail for inactive videos
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
var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) } var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) }
LaunchedEffect(url, activated) { var aspectRatio by remember { mutableFloatStateOf(16f / 9f) }
if (!activated && thumbnail == null) {
// Retry up to 3 times with increasing delay // Load thumbnail when not active
LaunchedEffect(url, isActiveVideo) {
if (!isActiveVideo && thumbnail == null) {
for (attempt in 1..3) { for (attempt in 1..3) {
val result = VideoThumbnailCache.getThumbnail(url) val result = VideoThumbnailCache.getThumbnail(url)
if (result != null) { if (result != null) {
@@ -112,153 +83,24 @@ fun DesktopVideoPlayer(
} }
} }
// Pause when another player becomes active // Auto-play on mount if requested
val activeId by ActiveMediaManager.activeId.collectAsState() LaunchedEffect(url, autoPlay) {
LaunchedEffect(activeId) { if (autoPlay) {
if (activeId != null && activeId != playerId && isPlaying) { GlobalMediaPlayer.playVideo(url, initialSeekPosition)
player?.controls()?.pause()
} }
} }
// Set up player off the UI thread when activated // Sync aspect ratio from global state when active
LaunchedEffect(url, activated) { if (isActiveVideo && videoState.aspectRatio != 16f / 9f) {
if (!activated) return@LaunchedEffect aspectRatio = videoState.aspectRatio
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<out ByteBuffer>) {}
}
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)
} }
// Clean up player on leave or URL change if (!VlcjPlayerPool.isAvailable() && VlcjPlayerPool.init().not()) {
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) {
VlcNotAvailableMessage(url, modifier) VlcNotAvailableMessage(url, modifier)
return return
} }
BoxWithConstraints(modifier = modifier) { BoxWithConstraints(modifier = modifier) {
// Calculate height from aspect ratio, clamped to max constraints
val desiredHeight = maxWidth / aspectRatio val desiredHeight = maxWidth / aspectRatio
val constrainedHeight = if (constraints.hasBoundedHeight) minOf(desiredHeight, maxHeight) else desiredHeight val constrainedHeight = if (constraints.hasBoundedHeight) minOf(desiredHeight, maxHeight) else desiredHeight
@@ -273,7 +115,7 @@ fun DesktopVideoPlayer(
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val displayBitmap = frame ?: thumbnail val displayBitmap: ImageBitmap? = if (isActiveVideo) videoFrame ?: thumbnail else thumbnail
displayBitmap?.let { bitmap -> displayBitmap?.let { bitmap ->
Image( Image(
bitmap = bitmap, bitmap = bitmap,
@@ -287,47 +129,38 @@ fun DesktopVideoPlayer(
} }
VideoControls( VideoControls(
isPlaying = isPlaying, isPlaying = if (isActiveVideo) videoState.isPlaying else false,
isBuffering = isBuffering, isBuffering = if (isActiveVideo) videoState.isBuffering else false,
position = position, position = if (isActiveVideo) videoState.position else 0f,
duration = duration, duration = if (isActiveVideo) videoState.duration else 0L,
currentTime = currentTime, currentTime = if (isActiveVideo) videoState.currentTime else 0L,
volume = volume, volume = if (isActiveVideo) videoState.volume else 100,
isMuted = isMuted, isMuted = if (isActiveVideo) videoState.isMuted else false,
viewMode = viewMode, viewMode = viewMode,
onPlayPause = { onPlayPause = {
val p = player if (isActiveVideo) {
if (p != null) { GlobalMediaPlayer.toggleVideoPlayPause()
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()
}
}
} else { } else {
// First play — activate lazy init GlobalMediaPlayer.playVideo(url, initialSeekPosition)
activated = true
} }
}, },
onSeek = { pos -> onSeek = { pos ->
player?.controls()?.setPosition(pos) if (isActiveVideo) {
GlobalMediaPlayer.seekVideo(pos)
}
}, },
onVolumeChange = { vol -> onVolumeChange = { vol ->
volume = vol GlobalMediaPlayer.setVideoVolume(vol)
player?.audio()?.setVolume(vol)
}, },
onMuteToggle = { onMuteToggle = {
isMuted = !isMuted GlobalMediaPlayer.toggleVideoMute()
player?.audio()?.isMute = isMuted
}, },
onFullscreen = onFullscreen =
if (onFullscreen != null) { if (onFullscreen != null) {
{ onFullscreen(position) } {
val pos = if (isActiveVideo) videoState.position else 0f
onFullscreen(pos)
}
} else { } else {
null null
}, },
@@ -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()
}
},
)
}
}
@@ -37,10 +37,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Check 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.ContentCopy
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.MoreVert 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.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed 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.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
@@ -231,7 +234,7 @@ fun LightboxOverlay(
} }
Key.S -> { Key.S -> {
if (event.isCtrlPressed) { if (event.isCtrlPressed || event.isMetaPressed) {
triggerSave() triggerSave()
true true
} else { } else {
@@ -247,13 +250,19 @@ fun LightboxOverlay(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
) { ) {
// Click on backdrop doesn't close — use X button or Esc if (viewMode != ViewMode.FULLSCREEN) onDismiss()
}, },
) { ) {
// Main content — video or image // Main content — video or image
if (isVideo) { if (isVideo) {
Box( Box(
modifier = contentModifier, modifier =
contentModifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
// Consume clicks so backdrop dismiss doesn't fire
},
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
DesktopVideoPlayer( 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 // Navigation arrows — hidden in fullscreen
if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) { if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) {
if (currentIndex > 0) { if (currentIndex > 0) {
@@ -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),
)
}
}
}
}
@@ -66,30 +66,40 @@ fun PictureDisplay(
Column { Column {
// Images // Images
for ((index, url) in imageUrls.withIndex()) { for ((index, url) in imageUrls.withIndex()) {
AsyncImage( val imageModifier =
model = url, Modifier
contentDescription = title, .fillMaxWidth()
modifier = .heightIn(max = 500.dp)
Modifier .clip(
.fillMaxWidth() if (index == 0 && title == null && description.isBlank()) {
.heightIn(max = 500.dp) RoundedCornerShape(8.dp)
.clip( } else if (index == 0) {
if (index == 0 && title == null && description.isBlank()) { RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
RoundedCornerShape(8.dp) } else {
} else if (index == 0) { RoundedCornerShape(0.dp)
RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp) },
} else { ).then(
RoundedCornerShape(0.dp) if (onImageClick != null) {
}, Modifier.clickable { onImageClick(imageUrls, index) }
).then( } else {
if (onImageClick != null) { Modifier
Modifier.clickable { onImageClick(imageUrls, index) } },
} else { )
Modifier if (isAnimatedGifUrl(url)) {
}, AnimatedGifImage(
), url = url,
contentScale = ContentScale.FillWidth, contentDescription = title,
) modifier = imageModifier,
contentScale = ContentScale.FillWidth,
)
} else {
AsyncImage(
model = url,
contentDescription = title,
modifier = imageModifier,
contentScale = ContentScale.FillWidth,
)
}
} }
// Title + description below images // Title + description below images
@@ -194,7 +194,7 @@ fun VideoControls(
Slider( Slider(
value = volume / 100f, value = volume / 100f,
onValueChange = { onVolumeChange((it * 100).toInt()) }, onValueChange = { onVolumeChange((it * 100).toInt()) },
modifier = Modifier.width(80.dp), modifier = Modifier.width(240.dp),
colors = colors =
SliderDefaults.colors( SliderDefaults.colors(
thumbColor = Color.White, thumbColor = Color.White,
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.desktop.ui.media package com.vitorpamplona.amethyst.desktop.ui.media
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -52,6 +53,17 @@ fun ZoomableImage(
modifier modifier
.fillMaxSize() .fillMaxSize()
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
scale = 1f
offsetX = 0f
offsetY = 0f
},
onTap = {
// Consume single taps so they don't propagate to backdrop
},
)
}.pointerInput(Unit) {
awaitPointerEventScope { awaitPointerEventScope {
while (true) { while (true) {
val event = awaitPointerEvent() val event = awaitPointerEvent()
@@ -77,19 +89,29 @@ fun ZoomableImage(
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
AsyncImage( val imageModifier =
model = url, Modifier
contentDescription = null, .fillMaxSize()
modifier = .graphicsLayer(
Modifier scaleX = scale,
.fillMaxSize() scaleY = scale,
.graphicsLayer( translationX = offsetX,
scaleX = scale, translationY = offsetY,
scaleY = scale, )
translationX = offsetX, if (isAnimatedGifUrl(url)) {
translationY = offsetY, AnimatedGifImage(
), url = url,
contentScale = ContentScale.Fit, contentDescription = null,
) modifier = imageModifier,
contentScale = ContentScale.Fit,
)
} else {
AsyncImage(
model = url,
contentDescription = null,
modifier = imageModifier,
contentScale = ContentScale.Fit,
)
}
} }
} }
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache 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.AudioPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState 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.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
@@ -155,60 +158,69 @@ fun NoteCard(
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant, containerColor = MaterialTheme.colorScheme.surfaceVariant,
), ),
onClick = onClick ?: {},
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Row( // Header + text area — clickable to navigate to thread
modifier = Modifier.fillMaxWidth(), Column(
horizontalArrangement = Arrangement.SpaceBetween, modifier =
verticalAlignment = Alignment.CenterVertically, if (onClick != null) {
Modifier.clickable { onClick() }
} else {
Modifier
},
) { ) {
// Author with avatar
Row( Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier =
if (onAuthorClick != null) {
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
} else {
Modifier
},
) { ) {
UserAvatar( // Author with avatar
userHex = note.pubKeyHex, Row(
pictureUrl = note.profilePictureUrl, verticalAlignment = Alignment.CenterVertically,
size = 32.dp, modifier =
contentDescription = "Profile picture of ${note.pubKeyDisplay}", 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(
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", text = note.createdAt.toTimeAgo(withDot = false),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
) )
} }
// Timestamp Spacer(Modifier.height(8.dp))
Text(
text = note.createdAt.toTimeAgo(withDot = false),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(8.dp)) if (strippedContent.isNotBlank()) {
RichTextContent(
if (strippedContent.isNotBlank()) { content = strippedContent,
RichTextContent( urls = strippedUrls,
content = strippedContent, localCache = localCache,
urls = strippedUrls, onMentionClick = onMentionClick,
localCache = localCache, modifier = Modifier.fillMaxWidth(),
onMentionClick = onMentionClick, )
modifier = Modifier.fillMaxWidth(), }
) } // end clickable header+text column
}
// Inline images // Inline images
if (imageUrls.isNotEmpty()) { if (imageUrls.isNotEmpty()) {
@@ -216,9 +228,7 @@ fun NoteCard(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
for ((index, url) in imageUrls.withIndex()) { for ((index, url) in imageUrls.withIndex()) {
AsyncImage( Box(
model = url,
contentDescription = null,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -231,8 +241,23 @@ fun NoteCard(
Modifier 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()) { if (url != imageUrls.last()) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.desktop.ui.settings package com.vitorpamplona.amethyst.desktop.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -106,15 +110,19 @@ fun MediaServerSettings(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Server list // Server list
LazyColumn( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f, fill = false),
) { ) {
items(servers) { server -> for (server in servers.toList()) {
ServerRow( ServerRow(
server = server, server = server,
status = serverStatuses[server] ?: ServerHealthCheck.ServerStatus.UNKNOWN, status = serverStatuses[server] ?: ServerHealthCheck.ServerStatus.UNKNOWN,
isDefault = servers.indexOf(server) == 0, isDefault = servers.indexOf(server) == 0,
onSetDefault = {
servers.remove(server)
servers.add(0, server)
onServersChanged(servers.toList())
},
onRemove = { onRemove = {
servers.remove(server) servers.remove(server)
serverStatuses.remove(server) serverStatuses.remove(server)
@@ -152,7 +160,7 @@ fun MediaServerSettings(
Button( Button(
onClick = { onClick = {
val url = newServerUrl.trim().removeSuffix("/") val url = newServerUrl.trim().removeSuffix("/")
if (url.isNotBlank() && url !in servers) { if (url.isNotBlank() && url !in servers && isValidServerUrl(url)) {
servers.add(url) servers.add(url)
newServerUrl = "" newServerUrl = ""
onServersChanged(servers.toList()) onServersChanged(servers.toList())
@@ -162,7 +170,7 @@ fun MediaServerSettings(
} }
} }
}, },
enabled = newServerUrl.isNotBlank(), enabled = newServerUrl.isNotBlank() && isValidServerUrl(newServerUrl.trim()),
) { ) {
Icon(Icons.Default.Add, contentDescription = "Add") Icon(Icons.Default.Add, contentDescription = "Add")
Spacer(Modifier.width(4.dp)) 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 @Composable
private fun ServerRow( private fun ServerRow(
server: String, server: String,
status: ServerHealthCheck.ServerStatus, status: ServerHealthCheck.ServerStatus,
isDefault: Boolean, isDefault: Boolean,
onSetDefault: () -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
) { ) {
@@ -221,17 +241,33 @@ private fun ServerRow(
modifier = Modifier.fillMaxWidth().padding(12.dp), modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Status indicator // Status indicator with tooltip
Surface( TooltipBox(
modifier = Modifier.size(12.dp), positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
shape = CircleShape, tooltip = {
color = PlainTooltip {
when (status) { Text(
ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50) when (status) {
ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336) ServerHealthCheck.ServerStatus.ONLINE -> "Online"
ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E) 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)) Spacer(Modifier.width(12.dp))
@@ -246,6 +282,13 @@ private fun ServerRow(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
} else {
Text(
"Set as default",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { onSetDefault() },
)
} }
} }
@@ -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 /<sha256> -- 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
<binary blob data>
```
- 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 /<sha256> -- 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 /<sha256>` 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 <base64url-encoded kind 24242 event>
Content-Type: image/png
Content-Length: 184292
X-SHA-256: b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553
<raw binary data>
```
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/<pubkey> -- 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 /<sha256> -- Delete Blob
```http
DELETE /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1
Host: cdn.example.com
Authorization: Nostr <base64url-encoded kind 24242 event>
```
- 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 <base64url-encoded kind 24242 event>
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 <base64url-encoded kind 24242 event with t=media>
Content-Type: image/png
Content-Length: 4194304
<raw binary data>
```
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 <base64url-encoded event>
```
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: "<NUT-24 cashu token>"
X-Lightning: "<BOLT-11 invoice>"
```
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", "<sha256>", "illegal"],
["p", "<uploader-pubkey>"]
]
}
```
Server maintains records for operator review. Optionally authorizes trusted moderators for autonomous removal.
---
## BUD-10: Blossom URI Scheme
Format:
```
blossom:<sha256>.<ext>[?param1=value1&param2=value2...]
```
Example:
```
blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.example.com&xs=backup.example.com&as=<author-hex-pubkey>&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 /<sha256>`
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": "<event-id>",
"pubkey": "<hex-pubkey>",
"created_at": 1725105921,
"kind": 24242,
"tags": [
["t", "upload"],
["x", "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"],
["expiration", "1725109521"],
["server", "cdn.example.com"]
],
"content": "Upload cat photo",
"sig": "<schnorr-signature>"
}
```
### 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 <base64url-encoded JSON of the kind 24242 event>
```
Note: Amethyst uses standard Base64 (not Base64url), which works in practice.
### Endpoint Authorization Requirements
| Endpoint | `t` tag | Hash source | `x` tag |
|----------|---------|-------------|---------|
| GET/HEAD /<sha256> | `get` | URL path | optional |
| PUT /upload | `upload` | X-SHA-256 header | required |
| HEAD /upload | `upload` | X-SHA-256 header | required |
| DELETE /<sha256> | `delete` | URL path | required |
| GET /list/<pubkey> | `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 <base64>`, `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.
@@ -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:<hash>.<ext>`
### 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<Int, Int>?
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", "<sha256>"], // scope to specific blob
["expiration", "<timestamp>"], // required
["server", "cdn.example.com"] // scope to server
]
)
// Sent as: Authorization: Nostr <base64(signedEvent)>
```
## 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?
@@ -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?
@@ -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<out ByteBuffer>) {
byteArray = ByteArray(buffers[0].limit())
}
},
object : RenderCallback {
override fun display(mp: MediaPlayer, bufs: Array<out ByteBuffer>, 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<File>
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 <b64>` 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=<hash>`, `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=<sha256>`, `expiration=+1hr`, `server=<domain>`
3. Base64-encode (standard base64 works despite spec saying base64url)
4. `PUT /upload` with `Authorization: Nostr <b64>`, `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<UploadState>` (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?