mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
co-authored by
Claude Opus 4.6
parent
6e1315a4fa
commit
2a6087af27
@@ -31,6 +31,8 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
@@ -146,7 +148,13 @@ sealed class DesktopScreen {
|
||||
|
||||
fun main() {
|
||||
DesktopImageLoaderSetup.setup()
|
||||
Runtime.getRuntime().addShutdownHook(Thread { VlcjPlayerPool.shutdown() })
|
||||
Runtime.getRuntime().addShutdownHook(
|
||||
Thread {
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.shutdown()
|
||||
VlcjPlayerPool.shutdown()
|
||||
},
|
||||
)
|
||||
// Pre-init VLC on background thread so first play is fast
|
||||
Thread { VlcjPlayerPool.init() }.start()
|
||||
application {
|
||||
@@ -687,61 +695,71 @@ fun MainContent(
|
||||
val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
when (layoutMode) {
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
SinglePaneLayout(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
LayoutMode.DECK -> {
|
||||
if (!isImmersive) {
|
||||
DeckSidebar(
|
||||
onAddColumn = onShowAddColumnDialog,
|
||||
onOpenSettings = {
|
||||
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
||||
deckState.focusExistingColumn(DeckColumnType.Settings)
|
||||
} else {
|
||||
deckState.addColumn(DeckColumnType.Settings)
|
||||
}
|
||||
},
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.fillMaxSize().weight(1f)) {
|
||||
when (layoutMode) {
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
SinglePaneLayout(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
}
|
||||
|
||||
DeckLayout(
|
||||
deckState = deckState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LayoutMode.DECK -> {
|
||||
if (!isImmersive) {
|
||||
DeckSidebar(
|
||||
onAddColumn = onShowAddColumnDialog,
|
||||
onOpenSettings = {
|
||||
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
||||
deckState.focusExistingColumn(DeckColumnType.Settings)
|
||||
} else {
|
||||
deckState.addColumn(DeckColumnType.Settings)
|
||||
}
|
||||
},
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
}
|
||||
|
||||
DeckLayout(
|
||||
deckState = deckState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
appScope = appScope,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Row
|
||||
|
||||
// Persistent media control bar
|
||||
com.vitorpamplona.amethyst.desktop.ui.media
|
||||
.NowPlayingBar()
|
||||
} // end Column
|
||||
|
||||
// Global fullscreen video overlay
|
||||
com.vitorpamplona.amethyst.desktop.ui.media
|
||||
.GlobalFullscreenOverlay()
|
||||
|
||||
// Snackbar for zap feedback
|
||||
SnackbarHost(
|
||||
@@ -804,7 +822,9 @@ fun RelaySettingsScreen(
|
||||
accountManager.loadNwcConnection()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
"Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
|
||||
+494
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -48,7 +48,7 @@ object VlcjPlayerPool {
|
||||
// Video player pool (for actual playback)
|
||||
private val allPlayers = mutableListOf<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)
|
||||
private val allThumbPlayers = mutableListOf<EmbeddedMediaPlayer>()
|
||||
@@ -59,7 +59,7 @@ object VlcjPlayerPool {
|
||||
private var audioFactory: MediaPlayerFactory? = null
|
||||
private val allAudioPlayers = mutableListOf<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.
|
||||
|
||||
+142
-139
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
@@ -122,15 +122,11 @@ fun FeedNoteCard(
|
||||
) {
|
||||
val zapAmountSats = zapReceipts.sumOf { it.amountSats }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
onNavigateToThread(event.id)
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
onImageClick = onImageClick,
|
||||
@@ -507,149 +503,156 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header with compose button — wraps on narrow columns
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header with compose button — wraps on narrow columns
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
// Feed mode selector
|
||||
if (account != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.GLOBAL,
|
||||
onClick = {
|
||||
feedMode = FeedMode.GLOBAL
|
||||
DesktopPreferences.feedMode = FeedMode.GLOBAL
|
||||
},
|
||||
label = { Text("Global") },
|
||||
// Feed mode selector
|
||||
if (account != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.GLOBAL,
|
||||
onClick = {
|
||||
feedMode = FeedMode.GLOBAL
|
||||
DesktopPreferences.feedMode = FeedMode.GLOBAL
|
||||
},
|
||||
label = { Text("Global") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.FOLLOWING,
|
||||
onClick = {
|
||||
feedMode = FeedMode.FOLLOWING
|
||||
DesktopPreferences.feedMode = FeedMode.FOLLOWING
|
||||
},
|
||||
label = { Text("Following") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${connectedRelays.size} relays connected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
Text(
|
||||
" • ${followedUsers.size} followed",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.FOLLOWING,
|
||||
onClick = {
|
||||
feedMode = FeedMode.FOLLOWING
|
||||
DesktopPreferences.feedMode = FeedMode.FOLLOWING
|
||||
},
|
||||
label = { Text("Following") },
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = { relayManager.connect() },
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${connectedRelays.size} relays connected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
Text(
|
||||
" • ${followedUsers.size} followed",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// New Post button (primary action)
|
||||
Button(
|
||||
onClick = onCompose,
|
||||
enabled = account != null && !account.isReadOnly,
|
||||
) {
|
||||
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = { relayManager.connect() },
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
Text("New Post")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
|
||||
LoadingState("Loading followed users...")
|
||||
} else if (events.isEmpty() && !initialLoadComplete) {
|
||||
LoadingState("Loading notes...")
|
||||
} else if (events.isEmpty() && initialLoadComplete) {
|
||||
EmptyState(
|
||||
title =
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
"No notes from followed users"
|
||||
} else {
|
||||
"No notes found"
|
||||
},
|
||||
description =
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
"Notes from people you follow will appear here"
|
||||
} else {
|
||||
"Notes from the network will appear here"
|
||||
},
|
||||
onRefresh = { relayManager.connect() },
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Use distinctBy to prevent duplicate key crashes from events with same ID
|
||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
FeedNoteCard(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = { replyToEvent = event },
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
|
||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
||||
replyCount = repliesByEvent[event.id] ?: 0,
|
||||
repostCount = repostsByEvent[event.id] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(event.id),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New Post button (primary action)
|
||||
Button(
|
||||
onClick = onCompose,
|
||||
enabled = account != null && !account.isReadOnly,
|
||||
) {
|
||||
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("New Post")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
|
||||
LoadingState("Loading followed users...")
|
||||
} else if (events.isEmpty() && !initialLoadComplete) {
|
||||
LoadingState("Loading notes...")
|
||||
} else if (events.isEmpty() && initialLoadComplete) {
|
||||
EmptyState(
|
||||
title =
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
"No notes from followed users"
|
||||
} else {
|
||||
"No notes found"
|
||||
},
|
||||
description =
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
"Notes from people you follow will appear here"
|
||||
} else {
|
||||
"Notes from the network will appear here"
|
||||
},
|
||||
onRefresh = { relayManager.connect() },
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Use distinctBy to prevent duplicate key crashes from events with same ID
|
||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
FeedNoteCard(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = { replyToEvent = event },
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
onMediaClick = { urls, index, seekPos -> lightboxState = LightboxState(urls, index, seekPos, fullscreen = true) },
|
||||
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
|
||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
||||
replyCount = repliesByEvent[event.id] ?: 0,
|
||||
repostCount = repostsByEvent[event.id] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(event.id),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Column
|
||||
|
||||
// Reply dialog
|
||||
if (replyToEvent != null && account != null) {
|
||||
@@ -671,5 +674,5 @@ fun FeedScreen(
|
||||
onDismiss = { lightboxState = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
} // end Box
|
||||
}
|
||||
|
||||
+179
-151
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -69,6 +70,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscriptio
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -137,6 +139,9 @@ fun ThreadScreen(
|
||||
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
|
||||
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
|
||||
LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) {
|
||||
if (subscriptionsCoordinator != null) {
|
||||
@@ -343,177 +348,200 @@ fun ThreadScreen(
|
||||
return level
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header with back button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
modifier = Modifier.size(24.dp),
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header with back button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Thread",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Thread",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else if (rootNote == null && !rootNoteEoseReceived) {
|
||||
LoadingState("Loading thread...")
|
||||
} else if (rootNote == null && rootNoteEoseReceived) {
|
||||
EmptyState(
|
||||
title = "Note not found",
|
||||
description = "This note may have been deleted or is not available from connected relays",
|
||||
onRefresh = onBack,
|
||||
refreshLabel = "Go back",
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
// Root note (no reply level indicator)
|
||||
item(key = noteId) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
// Already viewing this thread, no-op
|
||||
},
|
||||
) {
|
||||
NoteCard(
|
||||
note = rootNote!!.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
if (account != null) {
|
||||
val rootZaps = zapsByEvent[noteId] ?: emptyList()
|
||||
NoteActionsRow(
|
||||
event = rootNote!!,
|
||||
relayManager = relayManager,
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else if (rootNote == null && !rootNoteEoseReceived) {
|
||||
LoadingState("Loading thread...")
|
||||
} else if (rootNote == null && rootNoteEoseReceived) {
|
||||
EmptyState(
|
||||
title = "Note not found",
|
||||
description = "This note may have been deleted or is not available from connected relays",
|
||||
onRefresh = onBack,
|
||||
refreshLabel = "Go back",
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
// Root note (no reply level indicator)
|
||||
item(key = noteId) {
|
||||
Column {
|
||||
NoteCard(
|
||||
note = rootNote!!.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = { onReply(rootNote!!) },
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
zapCount = rootZaps.size,
|
||||
zapAmountSats = rootZaps.sumOf { it.amountSats },
|
||||
zapReceipts = rootZaps,
|
||||
reactionCount = reactionsByEvent[noteId] ?: 0,
|
||||
replyCount = repliesByEvent[noteId] ?: 0,
|
||||
repostCount = repostsByEvent[noteId] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(noteId),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
if (account != null) {
|
||||
val rootZaps = zapsByEvent[noteId] ?: emptyList()
|
||||
NoteActionsRow(
|
||||
event = rootNote!!,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = { onReply(rootNote!!) },
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
zapCount = rootZaps.size,
|
||||
zapAmountSats = rootZaps.sumOf { it.amountSats },
|
||||
zapReceipts = rootZaps,
|
||||
reactionCount = reactionsByEvent[noteId] ?: 0,
|
||||
replyCount = repliesByEvent[noteId] ?: 0,
|
||||
repostCount = repostsByEvent[noteId] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(noteId),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
|
||||
// Reply notes with level indicators
|
||||
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
val level = calculateLevel(event)
|
||||
// Reply notes with level indicators
|
||||
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
val level = calculateLevel(event)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.drawReplyLevel(
|
||||
level = level,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
selected =
|
||||
if (event.id == noteId) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
},
|
||||
).clickable {
|
||||
onNavigateToThread(event.id)
|
||||
},
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
if (account != null) {
|
||||
val eventZaps = zapsByEvent[event.id] ?: emptyList()
|
||||
NoteActionsRow(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.drawReplyLevel(
|
||||
level = level,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
selected =
|
||||
if (event.id == noteId) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
},
|
||||
).clickable {
|
||||
onNavigateToThread(event.id)
|
||||
},
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = { onReply(event) },
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
zapCount = eventZaps.size,
|
||||
zapAmountSats = eventZaps.sumOf { it.amountSats },
|
||||
zapReceipts = eventZaps,
|
||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
||||
replyCount = repliesByEvent[event.id] ?: 0,
|
||||
repostCount = repostsByEvent[event.id] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(event.id),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
if (account != null) {
|
||||
val eventZaps = zapsByEvent[event.id] ?: emptyList()
|
||||
NoteActionsRow(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = { onReply(event) },
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
zapCount = eventZaps.size,
|
||||
zapAmountSats = eventZaps.sumOf { it.amountSats },
|
||||
zapReceipts = eventZaps,
|
||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
||||
replyCount = repliesByEvent[event.id] ?: 0,
|
||||
repostCount = repostsByEvent[event.id] ?: 0,
|
||||
bookmarkList = bookmarkList,
|
||||
isBookmarked = bookmarkedEventIds.contains(event.id),
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
val pubIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
bookmarkedEventIds = pubIds
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
|
||||
// Empty state for no replies
|
||||
if (replyEvents.isEmpty() && repliesEoseReceived) {
|
||||
item {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
"No replies yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
} else if (replyEvents.isEmpty() && !repliesEoseReceived) {
|
||||
item {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
"Loading replies...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
// Empty state for no replies
|
||||
if (replyEvents.isEmpty() && repliesEoseReceived) {
|
||||
item {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
"No replies yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
} else if (replyEvents.isEmpty() && !repliesEoseReceived) {
|
||||
item {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
"Loading replies...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Column
|
||||
|
||||
// Lightbox overlay
|
||||
val lb = lightboxState
|
||||
if (lb != null) {
|
||||
LightboxOverlay(
|
||||
urls = lb.urls,
|
||||
initialIndex = lb.index,
|
||||
initialSeekPosition = lb.seekPosition,
|
||||
initialFullscreen = lb.fullscreen,
|
||||
onDismiss = { lightboxState = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
} // end Box
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-1
@@ -714,7 +714,10 @@ fun UserProfileScreen(
|
||||
lightboxState = LightboxState(urls, index)
|
||||
},
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
lightboxState = LightboxState(urls, index, seekPos, fullscreen = true)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
-42
@@ -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)
|
||||
}
|
||||
}
|
||||
+176
@@ -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
|
||||
}
|
||||
+13
-99
@@ -37,106 +37,26 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
||||
import kotlinx.coroutines.delay
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
|
||||
/**
|
||||
* Audio-only player using VLCJ with pooled audio players.
|
||||
* Uses VlcjPlayerPool's shared audio factory instead of creating one per instance.
|
||||
*/
|
||||
@Composable
|
||||
fun AudioPlayer(
|
||||
url: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isPlaying by remember { mutableStateOf(false) }
|
||||
var position by remember { mutableFloatStateOf(0f) }
|
||||
var duration by remember { mutableLongStateOf(0L) }
|
||||
var currentTime by remember { mutableLongStateOf(0L) }
|
||||
var vlcAvailable by remember { mutableStateOf(true) }
|
||||
var player by remember { mutableStateOf<MediaPlayer?>(null) }
|
||||
val audioState by GlobalMediaPlayer.audioState.collectAsState()
|
||||
val isActiveAudio = audioState.url == url
|
||||
|
||||
DisposableEffect(url) {
|
||||
val mp = VlcjPlayerPool.acquireAudioPlayer()
|
||||
if (mp == null) {
|
||||
vlcAvailable = false
|
||||
return@DisposableEffect onDispose {}
|
||||
}
|
||||
|
||||
val listener =
|
||||
object : MediaPlayerEventAdapter() {
|
||||
override fun playing(mediaPlayer: MediaPlayer) {
|
||||
isPlaying = true
|
||||
duration = mediaPlayer.status().length()
|
||||
}
|
||||
|
||||
override fun paused(mediaPlayer: MediaPlayer) {
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
override fun stopped(mediaPlayer: MediaPlayer) {
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
override fun positionChanged(
|
||||
mediaPlayer: MediaPlayer,
|
||||
newPosition: Float,
|
||||
) {
|
||||
position = newPosition
|
||||
currentTime = (newPosition * duration).toLong()
|
||||
}
|
||||
|
||||
override fun finished(mediaPlayer: MediaPlayer) {
|
||||
isPlaying = false
|
||||
position = 0f
|
||||
currentTime = 0L
|
||||
}
|
||||
}
|
||||
|
||||
mp.events().addMediaPlayerEventListener(listener)
|
||||
player = mp
|
||||
|
||||
onDispose {
|
||||
player = null
|
||||
mp.events().removeMediaPlayerEventListener(listener)
|
||||
VlcjPlayerPool.releaseAudioPlayer(mp)
|
||||
}
|
||||
}
|
||||
|
||||
// Position polling
|
||||
LaunchedEffect(isPlaying) {
|
||||
while (isPlaying) {
|
||||
delay(500)
|
||||
player?.let {
|
||||
position = it.status().position()
|
||||
currentTime = it.status().time()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!vlcAvailable) {
|
||||
Text(
|
||||
"Audio: $url (install VLC to play)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
val isPlaying = if (isActiveAudio) audioState.isPlaying else false
|
||||
val position = if (isActiveAudio) audioState.position else 0f
|
||||
val duration = if (isActiveAudio) audioState.duration else 0L
|
||||
val currentTime = if (isActiveAudio) audioState.currentTime else 0L
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
@@ -157,16 +77,10 @@ fun AudioPlayer(
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
player?.let { p ->
|
||||
if (isPlaying) {
|
||||
p.controls().pause()
|
||||
} else {
|
||||
if (position <= 0f && !p.status().isPlaying) {
|
||||
p.media().play(url)
|
||||
} else {
|
||||
p.controls().play()
|
||||
}
|
||||
}
|
||||
if (isActiveAudio) {
|
||||
GlobalMediaPlayer.toggleAudioPlayPause()
|
||||
} else {
|
||||
GlobalMediaPlayer.playAudio(url)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
@@ -185,7 +99,7 @@ fun AudioPlayer(
|
||||
|
||||
Slider(
|
||||
value = position,
|
||||
onValueChange = { player?.controls()?.setPosition(it) },
|
||||
onValueChange = { GlobalMediaPlayer.seekAudio(it) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
|
||||
+39
-206
@@ -31,13 +31,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -45,27 +42,12 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.skia.Bitmap
|
||||
import org.jetbrains.skia.ColorAlphaType
|
||||
import org.jetbrains.skia.ImageInfo
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
|
||||
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.UUID
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
|
||||
@Composable
|
||||
fun DesktopVideoPlayer(
|
||||
@@ -78,29 +60,18 @@ fun DesktopVideoPlayer(
|
||||
onViewModeChange: ((ViewMode) -> Unit)? = null,
|
||||
trailingControls: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
var frame by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||
var isPlaying by remember { mutableStateOf(false) }
|
||||
var isBuffering by remember { mutableStateOf(false) }
|
||||
var position by remember { mutableFloatStateOf(0f) }
|
||||
var duration by remember { mutableLongStateOf(0L) }
|
||||
var currentTime by remember { mutableLongStateOf(0L) }
|
||||
var aspectRatio by remember { mutableFloatStateOf(16f / 9f) }
|
||||
var vlcAvailable by remember { mutableStateOf(true) }
|
||||
var player by remember { mutableStateOf<EmbeddedMediaPlayer?>(null) }
|
||||
var volume by remember { mutableIntStateOf(100) }
|
||||
var isMuted by remember { mutableStateOf(false) }
|
||||
// Check if this URL is the active video
|
||||
val videoState by GlobalMediaPlayer.videoState.collectAsState()
|
||||
val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState()
|
||||
val isActiveVideo = videoState.url == url
|
||||
|
||||
// Unique ID for single-player enforcement
|
||||
val playerId = remember { UUID.randomUUID().toString() }
|
||||
|
||||
// Lazy activation — don't touch VLC until user clicks play (or autoPlay)
|
||||
var activated by remember { mutableStateOf(autoPlay) }
|
||||
|
||||
// Load thumbnail when not activated, with retry on failure
|
||||
// Thumbnail for inactive videos
|
||||
var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) }
|
||||
LaunchedEffect(url, activated) {
|
||||
if (!activated && thumbnail == null) {
|
||||
// Retry up to 3 times with increasing delay
|
||||
var aspectRatio by remember { mutableFloatStateOf(16f / 9f) }
|
||||
|
||||
// Load thumbnail when not active
|
||||
LaunchedEffect(url, isActiveVideo) {
|
||||
if (!isActiveVideo && thumbnail == null) {
|
||||
for (attempt in 1..3) {
|
||||
val result = VideoThumbnailCache.getThumbnail(url)
|
||||
if (result != null) {
|
||||
@@ -112,153 +83,24 @@ fun DesktopVideoPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
// Pause when another player becomes active
|
||||
val activeId by ActiveMediaManager.activeId.collectAsState()
|
||||
LaunchedEffect(activeId) {
|
||||
if (activeId != null && activeId != playerId && isPlaying) {
|
||||
player?.controls()?.pause()
|
||||
// Auto-play on mount if requested
|
||||
LaunchedEffect(url, autoPlay) {
|
||||
if (autoPlay) {
|
||||
GlobalMediaPlayer.playVideo(url, initialSeekPosition)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up player off the UI thread when activated
|
||||
LaunchedEffect(url, activated) {
|
||||
if (!activated) return@LaunchedEffect
|
||||
isBuffering = true
|
||||
|
||||
val acquired =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!VlcjPlayerPool.init()) return@withContext null
|
||||
VlcjPlayerPool.acquire()
|
||||
}
|
||||
|
||||
if (acquired == null) {
|
||||
vlcAvailable = false
|
||||
isBuffering = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
var skBitmap: Bitmap? = null
|
||||
var pixelBytes: ByteArray? = null
|
||||
var didSeek = initialSeekPosition <= 0f
|
||||
|
||||
val bufferFormatCallback =
|
||||
object : BufferFormatCallback {
|
||||
override fun getBufferFormat(
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
): BufferFormat {
|
||||
if (sourceHeight > 0) {
|
||||
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat()
|
||||
}
|
||||
val bmp = Bitmap()
|
||||
bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL))
|
||||
skBitmap = bmp
|
||||
pixelBytes = ByteArray(sourceWidth * sourceHeight * 4)
|
||||
return RV32BufferFormat(sourceWidth, sourceHeight)
|
||||
}
|
||||
|
||||
override fun allocatedBuffers(buffers: Array<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)
|
||||
// Sync aspect ratio from global state when active
|
||||
if (isActiveVideo && videoState.aspectRatio != 16f / 9f) {
|
||||
aspectRatio = videoState.aspectRatio
|
||||
}
|
||||
|
||||
// Clean up player on leave or URL change
|
||||
DisposableEffect(url) {
|
||||
onDispose {
|
||||
ActiveMediaManager.deactivate(playerId)
|
||||
player?.let { p ->
|
||||
VlcjPlayerPool.release(p)
|
||||
player = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Position polling (VLCJ events sometimes miss updates)
|
||||
LaunchedEffect(isPlaying) {
|
||||
while (isPlaying) {
|
||||
delay(500)
|
||||
player?.let {
|
||||
position = it.status().position()
|
||||
currentTime = it.status().time()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!vlcAvailable) {
|
||||
if (!VlcjPlayerPool.isAvailable() && VlcjPlayerPool.init().not()) {
|
||||
VlcNotAvailableMessage(url, modifier)
|
||||
return
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
// Calculate height from aspect ratio, clamped to max constraints
|
||||
val desiredHeight = maxWidth / aspectRatio
|
||||
val constrainedHeight = if (constraints.hasBoundedHeight) minOf(desiredHeight, maxHeight) else desiredHeight
|
||||
|
||||
@@ -273,7 +115,7 @@ fun DesktopVideoPlayer(
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val displayBitmap = frame ?: thumbnail
|
||||
val displayBitmap: ImageBitmap? = if (isActiveVideo) videoFrame ?: thumbnail else thumbnail
|
||||
displayBitmap?.let { bitmap ->
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
@@ -287,47 +129,38 @@ fun DesktopVideoPlayer(
|
||||
}
|
||||
|
||||
VideoControls(
|
||||
isPlaying = isPlaying,
|
||||
isBuffering = isBuffering,
|
||||
position = position,
|
||||
duration = duration,
|
||||
currentTime = currentTime,
|
||||
volume = volume,
|
||||
isMuted = isMuted,
|
||||
isPlaying = if (isActiveVideo) videoState.isPlaying else false,
|
||||
isBuffering = if (isActiveVideo) videoState.isBuffering else false,
|
||||
position = if (isActiveVideo) videoState.position else 0f,
|
||||
duration = if (isActiveVideo) videoState.duration else 0L,
|
||||
currentTime = if (isActiveVideo) videoState.currentTime else 0L,
|
||||
volume = if (isActiveVideo) videoState.volume else 100,
|
||||
isMuted = if (isActiveVideo) videoState.isMuted else false,
|
||||
viewMode = viewMode,
|
||||
onPlayPause = {
|
||||
val p = player
|
||||
if (p != null) {
|
||||
if (isPlaying) {
|
||||
p.controls().pause()
|
||||
} else {
|
||||
ActiveMediaManager.activate(playerId)
|
||||
if (position <= 0f && !p.status().isPlaying) {
|
||||
p.media().play(url)
|
||||
isBuffering = true
|
||||
} else {
|
||||
p.controls().play()
|
||||
}
|
||||
}
|
||||
if (isActiveVideo) {
|
||||
GlobalMediaPlayer.toggleVideoPlayPause()
|
||||
} else {
|
||||
// First play — activate lazy init
|
||||
activated = true
|
||||
GlobalMediaPlayer.playVideo(url, initialSeekPosition)
|
||||
}
|
||||
},
|
||||
onSeek = { pos ->
|
||||
player?.controls()?.setPosition(pos)
|
||||
if (isActiveVideo) {
|
||||
GlobalMediaPlayer.seekVideo(pos)
|
||||
}
|
||||
},
|
||||
onVolumeChange = { vol ->
|
||||
volume = vol
|
||||
player?.audio()?.setVolume(vol)
|
||||
GlobalMediaPlayer.setVideoVolume(vol)
|
||||
},
|
||||
onMuteToggle = {
|
||||
isMuted = !isMuted
|
||||
player?.audio()?.isMute = isMuted
|
||||
GlobalMediaPlayer.toggleVideoMute()
|
||||
},
|
||||
onFullscreen =
|
||||
if (onFullscreen != null) {
|
||||
{ onFullscreen(position) }
|
||||
{
|
||||
val pos = if (isActiveVideo) videoState.position else 0f
|
||||
onFullscreen(pos)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
|
||||
+137
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+42
-3
@@ -37,10 +37,12 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
@@ -71,6 +73,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isCtrlPressed
|
||||
import androidx.compose.ui.input.key.isMetaPressed
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
@@ -231,7 +234,7 @@ fun LightboxOverlay(
|
||||
}
|
||||
|
||||
Key.S -> {
|
||||
if (event.isCtrlPressed) {
|
||||
if (event.isCtrlPressed || event.isMetaPressed) {
|
||||
triggerSave()
|
||||
true
|
||||
} else {
|
||||
@@ -247,13 +250,19 @@ fun LightboxOverlay(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
// Click on backdrop doesn't close — use X button or Esc
|
||||
if (viewMode != ViewMode.FULLSCREEN) onDismiss()
|
||||
},
|
||||
) {
|
||||
// Main content — video or image
|
||||
if (isVideo) {
|
||||
Box(
|
||||
modifier = contentModifier,
|
||||
modifier =
|
||||
contentModifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
// Consume clicks so backdrop dismiss doesn't fire
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DesktopVideoPlayer(
|
||||
@@ -397,6 +406,36 @@ fun LightboxOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
// Close button (top-left) — hidden in fullscreen
|
||||
if (viewMode != ViewMode.FULLSCREEN) {
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Image counter (bottom-center) — hidden in fullscreen
|
||||
if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) {
|
||||
Text(
|
||||
text = "${currentIndex + 1} / ${urls.size}",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp)
|
||||
.background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Navigation arrows — hidden in fullscreen
|
||||
if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) {
|
||||
if (currentIndex > 0) {
|
||||
|
||||
+270
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-24
@@ -66,30 +66,40 @@ fun PictureDisplay(
|
||||
Column {
|
||||
// Images
|
||||
for ((index, url) in imageUrls.withIndex()) {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = title,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 500.dp)
|
||||
.clip(
|
||||
if (index == 0 && title == null && description.isBlank()) {
|
||||
RoundedCornerShape(8.dp)
|
||||
} else if (index == 0) {
|
||||
RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
|
||||
} else {
|
||||
RoundedCornerShape(0.dp)
|
||||
},
|
||||
).then(
|
||||
if (onImageClick != null) {
|
||||
Modifier.clickable { onImageClick(imageUrls, index) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
val imageModifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 500.dp)
|
||||
.clip(
|
||||
if (index == 0 && title == null && description.isBlank()) {
|
||||
RoundedCornerShape(8.dp)
|
||||
} else if (index == 0) {
|
||||
RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
|
||||
} else {
|
||||
RoundedCornerShape(0.dp)
|
||||
},
|
||||
).then(
|
||||
if (onImageClick != null) {
|
||||
Modifier.clickable { onImageClick(imageUrls, index) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
if (isAnimatedGifUrl(url)) {
|
||||
AnimatedGifImage(
|
||||
url = url,
|
||||
contentDescription = title,
|
||||
modifier = imageModifier,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = title,
|
||||
modifier = imageModifier,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title + description below images
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ fun VideoControls(
|
||||
Slider(
|
||||
value = volume / 100f,
|
||||
onValueChange = { onVolumeChange((it * 100).toInt()) },
|
||||
modifier = Modifier.width(80.dp),
|
||||
modifier = Modifier.width(240.dp),
|
||||
colors =
|
||||
SliderDefaults.colors(
|
||||
thumbColor = Color.White,
|
||||
|
||||
+36
-14
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.desktop.ui.media
|
||||
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -52,6 +53,17 @@ fun ZoomableImage(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onDoubleTap = {
|
||||
scale = 1f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
},
|
||||
onTap = {
|
||||
// Consume single taps so they don't propagate to backdrop
|
||||
},
|
||||
)
|
||||
}.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
@@ -77,19 +89,29 @@ fun ZoomableImage(
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = offsetX,
|
||||
translationY = offsetY,
|
||||
),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
val imageModifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = offsetX,
|
||||
translationY = offsetY,
|
||||
)
|
||||
if (isAnimatedGifUrl(url)) {
|
||||
AnimatedGifImage(
|
||||
url = url,
|
||||
contentDescription = null,
|
||||
modifier = imageModifier,
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
modifier = imageModifier,
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+71
-46
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui.note
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -57,9 +58,11 @@ import com.vitorpamplona.amethyst.commons.richtext.Urls
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||
@@ -155,60 +158,69 @@ fun NoteCard(
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
onClick = onClick ?: {},
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// Header + text area — clickable to navigate to thread
|
||||
Column(
|
||||
modifier =
|
||||
if (onClick != null) {
|
||||
Modifier.clickable { onClick() }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
) {
|
||||
// Author with avatar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
if (onAuthorClick != null) {
|
||||
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = note.pubKeyHex,
|
||||
pictureUrl = note.profilePictureUrl,
|
||||
size = 32.dp,
|
||||
contentDescription = "Profile picture of ${note.pubKeyDisplay}",
|
||||
)
|
||||
// Author with avatar
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
if (onAuthorClick != null) {
|
||||
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = note.pubKeyHex,
|
||||
pictureUrl = note.profilePictureUrl,
|
||||
size = 32.dp,
|
||||
contentDescription = "Profile picture of ${note.pubKeyDisplay}",
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Text(
|
||||
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
text = note.createdAt.toTimeAgo(withDot = false),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Text(
|
||||
text = note.createdAt.toTimeAgo(withDot = false),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (strippedContent.isNotBlank()) {
|
||||
RichTextContent(
|
||||
content = strippedContent,
|
||||
urls = strippedUrls,
|
||||
localCache = localCache,
|
||||
onMentionClick = onMentionClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
if (strippedContent.isNotBlank()) {
|
||||
RichTextContent(
|
||||
content = strippedContent,
|
||||
urls = strippedUrls,
|
||||
localCache = localCache,
|
||||
onMentionClick = onMentionClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} // end clickable header+text column
|
||||
|
||||
// Inline images
|
||||
if (imageUrls.isNotEmpty()) {
|
||||
@@ -216,9 +228,7 @@ fun NoteCard(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
for ((index, url) in imageUrls.withIndex()) {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -231,8 +241,23 @@ fun NoteCard(
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
) {
|
||||
if (isAnimatedGifUrl(url)) {
|
||||
AnimatedGifImage(
|
||||
url = url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (url != imageUrls.last()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
+61
-18
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -29,8 +30,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
@@ -40,12 +39,17 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -106,15 +110,19 @@ fun MediaServerSettings(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Server list
|
||||
LazyColumn(
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
items(servers) { server ->
|
||||
for (server in servers.toList()) {
|
||||
ServerRow(
|
||||
server = server,
|
||||
status = serverStatuses[server] ?: ServerHealthCheck.ServerStatus.UNKNOWN,
|
||||
isDefault = servers.indexOf(server) == 0,
|
||||
onSetDefault = {
|
||||
servers.remove(server)
|
||||
servers.add(0, server)
|
||||
onServersChanged(servers.toList())
|
||||
},
|
||||
onRemove = {
|
||||
servers.remove(server)
|
||||
serverStatuses.remove(server)
|
||||
@@ -152,7 +160,7 @@ fun MediaServerSettings(
|
||||
Button(
|
||||
onClick = {
|
||||
val url = newServerUrl.trim().removeSuffix("/")
|
||||
if (url.isNotBlank() && url !in servers) {
|
||||
if (url.isNotBlank() && url !in servers && isValidServerUrl(url)) {
|
||||
servers.add(url)
|
||||
newServerUrl = ""
|
||||
onServersChanged(servers.toList())
|
||||
@@ -162,7 +170,7 @@ fun MediaServerSettings(
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = newServerUrl.isNotBlank(),
|
||||
enabled = newServerUrl.isNotBlank() && isValidServerUrl(newServerUrl.trim()),
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add")
|
||||
Spacer(Modifier.width(4.dp))
|
||||
@@ -202,11 +210,23 @@ fun MediaServerSettings(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isValidServerUrl(url: String): Boolean {
|
||||
val trimmed = url.trim().removeSuffix("/")
|
||||
return try {
|
||||
val uri = java.net.URI(trimmed)
|
||||
uri.scheme in listOf("https", "http") && uri.host != null && uri.host.contains(".")
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ServerRow(
|
||||
server: String,
|
||||
status: ServerHealthCheck.ServerStatus,
|
||||
isDefault: Boolean,
|
||||
onSetDefault: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
@@ -221,17 +241,33 @@ private fun ServerRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Status indicator
|
||||
Surface(
|
||||
modifier = Modifier.size(12.dp),
|
||||
shape = CircleShape,
|
||||
color =
|
||||
when (status) {
|
||||
ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50)
|
||||
ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336)
|
||||
ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E)
|
||||
},
|
||||
) {}
|
||||
// Status indicator with tooltip
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(
|
||||
when (status) {
|
||||
ServerHealthCheck.ServerStatus.ONLINE -> "Online"
|
||||
ServerHealthCheck.ServerStatus.OFFLINE -> "Offline — server unreachable"
|
||||
ServerHealthCheck.ServerStatus.UNKNOWN -> "Checking..."
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(12.dp),
|
||||
shape = CircleShape,
|
||||
color =
|
||||
when (status) {
|
||||
ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50)
|
||||
ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336)
|
||||
ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E)
|
||||
},
|
||||
) {}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
@@ -246,6 +282,13 @@ private fun ServerRow(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Set as default",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.clickable { onSetDefault() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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¶m2=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?
|
||||
Reference in New Issue
Block a user