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:
@@ -31,6 +31,8 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -146,7 +148,13 @@ sealed class DesktopScreen {
|
|||||||
|
|
||||||
fun main() {
|
fun main() {
|
||||||
DesktopImageLoaderSetup.setup()
|
DesktopImageLoaderSetup.setup()
|
||||||
Runtime.getRuntime().addShutdownHook(Thread { VlcjPlayerPool.shutdown() })
|
Runtime.getRuntime().addShutdownHook(
|
||||||
|
Thread {
|
||||||
|
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
|
.shutdown()
|
||||||
|
VlcjPlayerPool.shutdown()
|
||||||
|
},
|
||||||
|
)
|
||||||
// Pre-init VLC on background thread so first play is fast
|
// Pre-init VLC on background thread so first play is fast
|
||||||
Thread { VlcjPlayerPool.init() }.start()
|
Thread { VlcjPlayerPool.init() }.start()
|
||||||
application {
|
application {
|
||||||
@@ -687,61 +695,71 @@ fun MainContent(
|
|||||||
val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current
|
val isImmersive by com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen.current
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Row(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
when (layoutMode) {
|
Row(Modifier.fillMaxSize().weight(1f)) {
|
||||||
LayoutMode.SINGLE_PANE -> {
|
when (layoutMode) {
|
||||||
SinglePaneLayout(
|
LayoutMode.SINGLE_PANE -> {
|
||||||
relayManager = relayManager,
|
SinglePaneLayout(
|
||||||
localCache = localCache,
|
relayManager = relayManager,
|
||||||
accountManager = accountManager,
|
localCache = localCache,
|
||||||
account = account,
|
accountManager = accountManager,
|
||||||
nwcConnection = nwcConnection,
|
account = account,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
nwcConnection = nwcConnection,
|
||||||
appScope = appScope,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
appScope = appScope,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onZapFeedback = onZapFeedback,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
signerConnectionState = signerConnectionState,
|
onZapFeedback = onZapFeedback,
|
||||||
lastPingTimeSec = lastPingTimeSec,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LayoutMode.DECK -> {
|
|
||||||
if (!isImmersive) {
|
|
||||||
DeckSidebar(
|
|
||||||
onAddColumn = onShowAddColumnDialog,
|
|
||||||
onOpenSettings = {
|
|
||||||
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
|
||||||
deckState.focusExistingColumn(DeckColumnType.Settings)
|
|
||||||
} else {
|
|
||||||
deckState.addColumn(DeckColumnType.Settings)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
signerConnectionState = signerConnectionState,
|
signerConnectionState = signerConnectionState,
|
||||||
lastPingTimeSec = lastPingTimeSec,
|
lastPingTimeSec = lastPingTimeSec,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
VerticalDivider()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DeckLayout(
|
LayoutMode.DECK -> {
|
||||||
deckState = deckState,
|
if (!isImmersive) {
|
||||||
relayManager = relayManager,
|
DeckSidebar(
|
||||||
localCache = localCache,
|
onAddColumn = onShowAddColumnDialog,
|
||||||
accountManager = accountManager,
|
onOpenSettings = {
|
||||||
account = account,
|
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
|
||||||
nwcConnection = nwcConnection,
|
deckState.focusExistingColumn(DeckColumnType.Settings)
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
} else {
|
||||||
appScope = appScope,
|
deckState.addColumn(DeckColumnType.Settings)
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
}
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
},
|
||||||
onZapFeedback = onZapFeedback,
|
signerConnectionState = signerConnectionState,
|
||||||
modifier = Modifier.weight(1f),
|
lastPingTimeSec = lastPingTimeSec,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
VerticalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckLayout(
|
||||||
|
deckState = deckState,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
accountManager = accountManager,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
appScope = appScope,
|
||||||
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} // end Row
|
||||||
}
|
|
||||||
|
// Persistent media control bar
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.media
|
||||||
|
.NowPlayingBar()
|
||||||
|
} // end Column
|
||||||
|
|
||||||
|
// Global fullscreen video overlay
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.media
|
||||||
|
.GlobalFullscreenOverlay()
|
||||||
|
|
||||||
// Snackbar for zap feedback
|
// Snackbar for zap feedback
|
||||||
SnackbarHost(
|
SnackbarHost(
|
||||||
@@ -804,7 +822,9 @@ fun RelaySettingsScreen(
|
|||||||
accountManager.loadNwcConnection()
|
accountManager.loadNwcConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Settings",
|
"Settings",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
|||||||
+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)
|
// Video player pool (for actual playback)
|
||||||
private val allPlayers = mutableListOf<EmbeddedMediaPlayer>()
|
private val allPlayers = mutableListOf<EmbeddedMediaPlayer>()
|
||||||
private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
|
private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
|
||||||
private const val MAX_POOL_SIZE = 4
|
private const val MAX_POOL_SIZE = 1
|
||||||
|
|
||||||
// Thumbnail player pool (separate so thumbnails don't compete with playback)
|
// Thumbnail player pool (separate so thumbnails don't compete with playback)
|
||||||
private val allThumbPlayers = mutableListOf<EmbeddedMediaPlayer>()
|
private val allThumbPlayers = mutableListOf<EmbeddedMediaPlayer>()
|
||||||
@@ -59,7 +59,7 @@ object VlcjPlayerPool {
|
|||||||
private var audioFactory: MediaPlayerFactory? = null
|
private var audioFactory: MediaPlayerFactory? = null
|
||||||
private val allAudioPlayers = mutableListOf<MediaPlayer>()
|
private val allAudioPlayers = mutableListOf<MediaPlayer>()
|
||||||
private val idleAudioPlayers = ConcurrentLinkedQueue<MediaPlayer>()
|
private val idleAudioPlayers = ConcurrentLinkedQueue<MediaPlayer>()
|
||||||
private const val MAX_AUDIO_POOL_SIZE = 5
|
private const val MAX_AUDIO_POOL_SIZE = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the pool. Thread-safe — only runs once.
|
* Initialize the pool. Thread-safe — only runs once.
|
||||||
|
|||||||
+142
-139
@@ -20,8 +20,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui
|
package com.vitorpamplona.amethyst.desktop.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
@@ -122,15 +122,11 @@ fun FeedNoteCard(
|
|||||||
) {
|
) {
|
||||||
val zapAmountSats = zapReceipts.sumOf { it.amountSats }
|
val zapAmountSats = zapReceipts.sumOf { it.amountSats }
|
||||||
|
|
||||||
Column(
|
Column {
|
||||||
modifier =
|
|
||||||
Modifier.clickable {
|
|
||||||
onNavigateToThread(event.id)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
NoteCard(
|
NoteCard(
|
||||||
note = event.toNoteDisplayData(localCache),
|
note = event.toNoteDisplayData(localCache),
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
|
onClick = { onNavigateToThread(event.id) },
|
||||||
onAuthorClick = onNavigateToProfile,
|
onAuthorClick = onNavigateToProfile,
|
||||||
onMentionClick = onNavigateToProfile,
|
onMentionClick = onNavigateToProfile,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
@@ -507,149 +503,156 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header with compose button — wraps on narrow columns
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
FlowRow(
|
// Header with compose button — wraps on narrow columns
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
Column {
|
) {
|
||||||
FlowRow(
|
Column {
|
||||||
verticalArrangement = Arrangement.Center,
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
Text(
|
) {
|
||||||
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
|
Text(
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
)
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
|
||||||
// Feed mode selector
|
// Feed mode selector
|
||||||
if (account != null) {
|
if (account != null) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = feedMode == FeedMode.GLOBAL,
|
selected = feedMode == FeedMode.GLOBAL,
|
||||||
onClick = {
|
onClick = {
|
||||||
feedMode = FeedMode.GLOBAL
|
feedMode = FeedMode.GLOBAL
|
||||||
DesktopPreferences.feedMode = FeedMode.GLOBAL
|
DesktopPreferences.feedMode = FeedMode.GLOBAL
|
||||||
},
|
},
|
||||||
label = { Text("Global") },
|
label = { Text("Global") },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = feedMode == FeedMode.FOLLOWING,
|
||||||
|
onClick = {
|
||||||
|
feedMode = FeedMode.FOLLOWING
|
||||||
|
DesktopPreferences.feedMode = FeedMode.FOLLOWING
|
||||||
|
},
|
||||||
|
label = { Text("Following") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"${connectedRelays.size} relays connected",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (feedMode == FeedMode.FOLLOWING) {
|
||||||
|
Text(
|
||||||
|
" • ${followedUsers.size} followed",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
FilterChip(
|
}
|
||||||
selected = feedMode == FeedMode.FOLLOWING,
|
Spacer(Modifier.width(8.dp))
|
||||||
onClick = {
|
IconButton(
|
||||||
feedMode = FeedMode.FOLLOWING
|
onClick = { relayManager.connect() },
|
||||||
DesktopPreferences.feedMode = FeedMode.FOLLOWING
|
modifier = Modifier.size(24.dp),
|
||||||
},
|
) {
|
||||||
label = { Text("Following") },
|
Icon(
|
||||||
|
Icons.Default.Refresh,
|
||||||
|
contentDescription = "Refresh",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(4.dp))
|
// New Post button (primary action)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Button(
|
||||||
Text(
|
onClick = onCompose,
|
||||||
"${connectedRelays.size} relays connected",
|
enabled = account != null && !account.isReadOnly,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
|
||||||
)
|
|
||||||
if (feedMode == FeedMode.FOLLOWING) {
|
|
||||||
Text(
|
|
||||||
" • ${followedUsers.size} followed",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
IconButton(
|
Text("New Post")
|
||||||
onClick = { relayManager.connect() },
|
}
|
||||||
modifier = Modifier.size(24.dp),
|
}
|
||||||
) {
|
|
||||||
Icon(
|
Spacer(Modifier.height(8.dp))
|
||||||
Icons.Default.Refresh,
|
|
||||||
contentDescription = "Refresh",
|
if (connectedRelays.isEmpty()) {
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
LoadingState("Connecting to relays...")
|
||||||
modifier = Modifier.size(18.dp),
|
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
|
||||||
|
LoadingState("Loading followed users...")
|
||||||
|
} else if (events.isEmpty() && !initialLoadComplete) {
|
||||||
|
LoadingState("Loading notes...")
|
||||||
|
} else if (events.isEmpty() && initialLoadComplete) {
|
||||||
|
EmptyState(
|
||||||
|
title =
|
||||||
|
if (feedMode == FeedMode.FOLLOWING) {
|
||||||
|
"No notes from followed users"
|
||||||
|
} else {
|
||||||
|
"No notes found"
|
||||||
|
},
|
||||||
|
description =
|
||||||
|
if (feedMode == FeedMode.FOLLOWING) {
|
||||||
|
"Notes from people you follow will appear here"
|
||||||
|
} else {
|
||||||
|
"Notes from the network will appear here"
|
||||||
|
},
|
||||||
|
onRefresh = { relayManager.connect() },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
// Use distinctBy to prevent duplicate key crashes from events with same ID
|
||||||
|
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||||
|
FeedNoteCard(
|
||||||
|
event = event,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
onReply = { replyToEvent = event },
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||||
|
onMediaClick = { urls, index, seekPos ->
|
||||||
|
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
|
.playVideo(urls[index], seekPos)
|
||||||
|
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
|
.toggleFullscreen()
|
||||||
|
},
|
||||||
|
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
|
||||||
|
reactionCount = reactionsByEvent[event.id] ?: 0,
|
||||||
|
replyCount = repliesByEvent[event.id] ?: 0,
|
||||||
|
repostCount = repostsByEvent[event.id] ?: 0,
|
||||||
|
bookmarkList = bookmarkList,
|
||||||
|
isBookmarked = bookmarkedEventIds.contains(event.id),
|
||||||
|
onBookmarkChanged = { newList ->
|
||||||
|
bookmarkList = newList
|
||||||
|
val pubIds =
|
||||||
|
newList
|
||||||
|
.publicBookmarks()
|
||||||
|
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
|
||||||
|
.map { it.eventId }
|
||||||
|
.toSet()
|
||||||
|
bookmarkedEventIds = pubIds
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // end Column
|
||||||
// New Post button (primary action)
|
|
||||||
Button(
|
|
||||||
onClick = onCompose,
|
|
||||||
enabled = account != null && !account.isReadOnly,
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("New Post")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
if (connectedRelays.isEmpty()) {
|
|
||||||
LoadingState("Connecting to relays...")
|
|
||||||
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
|
|
||||||
LoadingState("Loading followed users...")
|
|
||||||
} else if (events.isEmpty() && !initialLoadComplete) {
|
|
||||||
LoadingState("Loading notes...")
|
|
||||||
} else if (events.isEmpty() && initialLoadComplete) {
|
|
||||||
EmptyState(
|
|
||||||
title =
|
|
||||||
if (feedMode == FeedMode.FOLLOWING) {
|
|
||||||
"No notes from followed users"
|
|
||||||
} else {
|
|
||||||
"No notes found"
|
|
||||||
},
|
|
||||||
description =
|
|
||||||
if (feedMode == FeedMode.FOLLOWING) {
|
|
||||||
"Notes from people you follow will appear here"
|
|
||||||
} else {
|
|
||||||
"Notes from the network will appear here"
|
|
||||||
},
|
|
||||||
onRefresh = { relayManager.connect() },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
// Use distinctBy to prevent duplicate key crashes from events with same ID
|
|
||||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
|
||||||
FeedNoteCard(
|
|
||||||
event = event,
|
|
||||||
relayManager = relayManager,
|
|
||||||
localCache = localCache,
|
|
||||||
account = account,
|
|
||||||
nwcConnection = nwcConnection,
|
|
||||||
onReply = { replyToEvent = event },
|
|
||||||
onZapFeedback = onZapFeedback,
|
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
|
||||||
onNavigateToThread = onNavigateToThread,
|
|
||||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
|
||||||
onMediaClick = { urls, index, seekPos -> lightboxState = LightboxState(urls, index, seekPos, fullscreen = true) },
|
|
||||||
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
|
|
||||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
|
||||||
replyCount = repliesByEvent[event.id] ?: 0,
|
|
||||||
repostCount = repostsByEvent[event.id] ?: 0,
|
|
||||||
bookmarkList = bookmarkList,
|
|
||||||
isBookmarked = bookmarkedEventIds.contains(event.id),
|
|
||||||
onBookmarkChanged = { newList ->
|
|
||||||
bookmarkList = newList
|
|
||||||
val pubIds =
|
|
||||||
newList
|
|
||||||
.publicBookmarks()
|
|
||||||
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
|
|
||||||
.map { it.eventId }
|
|
||||||
.toSet()
|
|
||||||
bookmarkedEventIds = pubIds
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reply dialog
|
// Reply dialog
|
||||||
if (replyToEvent != null && account != null) {
|
if (replyToEvent != null && account != null) {
|
||||||
@@ -671,5 +674,5 @@ fun FeedScreen(
|
|||||||
onDismiss = { lightboxState = null },
|
onDismiss = { lightboxState = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
} // end Box
|
||||||
}
|
}
|
||||||
|
|||||||
+179
-151
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -69,6 +70,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscriptio
|
|||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
|
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
@@ -137,6 +139,9 @@ fun ThreadScreen(
|
|||||||
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
|
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
|
||||||
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
|
|
||||||
|
// Lightbox state
|
||||||
|
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||||
|
|
||||||
// Load metadata for thread authors + mentioned users via coordinator
|
// Load metadata for thread authors + mentioned users via coordinator
|
||||||
LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) {
|
LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) {
|
||||||
if (subscriptionsCoordinator != null) {
|
if (subscriptionsCoordinator != null) {
|
||||||
@@ -343,177 +348,200 @@ fun ThreadScreen(
|
|||||||
return level
|
return level
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header with back button
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Row(
|
// Header with back button
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||||
) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
IconButton(onClick = onBack) {
|
) {
|
||||||
Icon(
|
IconButton(onClick = onBack) {
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
Icon(
|
||||||
contentDescription = "Back",
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
modifier = Modifier.size(24.dp),
|
contentDescription = "Back",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"Thread",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
"Thread",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectedRelays.isEmpty()) {
|
if (connectedRelays.isEmpty()) {
|
||||||
LoadingState("Connecting to relays...")
|
LoadingState("Connecting to relays...")
|
||||||
} else if (rootNote == null && !rootNoteEoseReceived) {
|
} else if (rootNote == null && !rootNoteEoseReceived) {
|
||||||
LoadingState("Loading thread...")
|
LoadingState("Loading thread...")
|
||||||
} else if (rootNote == null && rootNoteEoseReceived) {
|
} else if (rootNote == null && rootNoteEoseReceived) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = "Note not found",
|
title = "Note not found",
|
||||||
description = "This note may have been deleted or is not available from connected relays",
|
description = "This note may have been deleted or is not available from connected relays",
|
||||||
onRefresh = onBack,
|
onRefresh = onBack,
|
||||||
refreshLabel = "Go back",
|
refreshLabel = "Go back",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
) {
|
) {
|
||||||
// Root note (no reply level indicator)
|
// Root note (no reply level indicator)
|
||||||
item(key = noteId) {
|
item(key = noteId) {
|
||||||
Column(
|
Column {
|
||||||
modifier =
|
NoteCard(
|
||||||
Modifier.clickable {
|
note = rootNote!!.toNoteDisplayData(localCache),
|
||||||
// Already viewing this thread, no-op
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
NoteCard(
|
|
||||||
note = rootNote!!.toNoteDisplayData(localCache),
|
|
||||||
localCache = localCache,
|
|
||||||
onAuthorClick = onNavigateToProfile,
|
|
||||||
onMentionClick = onNavigateToProfile,
|
|
||||||
)
|
|
||||||
if (account != null) {
|
|
||||||
val rootZaps = zapsByEvent[noteId] ?: emptyList()
|
|
||||||
NoteActionsRow(
|
|
||||||
event = rootNote!!,
|
|
||||||
relayManager = relayManager,
|
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
account = account,
|
onAuthorClick = onNavigateToProfile,
|
||||||
nwcConnection = nwcConnection,
|
onMentionClick = onNavigateToProfile,
|
||||||
onReplyClick = { onReply(rootNote!!) },
|
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||||
onZapFeedback = onZapFeedback,
|
onMediaClick = { urls, index, seekPos ->
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
zapCount = rootZaps.size,
|
.playVideo(urls[index], seekPos)
|
||||||
zapAmountSats = rootZaps.sumOf { it.amountSats },
|
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
zapReceipts = rootZaps,
|
.toggleFullscreen()
|
||||||
reactionCount = reactionsByEvent[noteId] ?: 0,
|
|
||||||
replyCount = repliesByEvent[noteId] ?: 0,
|
|
||||||
repostCount = repostsByEvent[noteId] ?: 0,
|
|
||||||
bookmarkList = bookmarkList,
|
|
||||||
isBookmarked = bookmarkedEventIds.contains(noteId),
|
|
||||||
onBookmarkChanged = { newList ->
|
|
||||||
bookmarkList = newList
|
|
||||||
val pubIds =
|
|
||||||
newList
|
|
||||||
.publicBookmarks()
|
|
||||||
.filterIsInstance<EventBookmark>()
|
|
||||||
.map { it.eventId }
|
|
||||||
.toSet()
|
|
||||||
bookmarkedEventIds = pubIds
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if (account != null) {
|
||||||
|
val rootZaps = zapsByEvent[noteId] ?: emptyList()
|
||||||
|
NoteActionsRow(
|
||||||
|
event = rootNote!!,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
onReplyClick = { onReply(rootNote!!) },
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
zapCount = rootZaps.size,
|
||||||
|
zapAmountSats = rootZaps.sumOf { it.amountSats },
|
||||||
|
zapReceipts = rootZaps,
|
||||||
|
reactionCount = reactionsByEvent[noteId] ?: 0,
|
||||||
|
replyCount = repliesByEvent[noteId] ?: 0,
|
||||||
|
repostCount = repostsByEvent[noteId] ?: 0,
|
||||||
|
bookmarkList = bookmarkList,
|
||||||
|
isBookmarked = bookmarkedEventIds.contains(noteId),
|
||||||
|
onBookmarkChanged = { newList ->
|
||||||
|
bookmarkList = newList
|
||||||
|
val pubIds =
|
||||||
|
newList
|
||||||
|
.publicBookmarks()
|
||||||
|
.filterIsInstance<EventBookmark>()
|
||||||
|
.map { it.eventId }
|
||||||
|
.toSet()
|
||||||
|
bookmarkedEventIds = pubIds
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
HorizontalDivider(thickness = 1.dp)
|
||||||
}
|
}
|
||||||
HorizontalDivider(thickness = 1.dp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reply notes with level indicators
|
// Reply notes with level indicators
|
||||||
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
|
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
|
||||||
val level = calculateLevel(event)
|
val level = calculateLevel(event)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.drawReplyLevel(
|
.drawReplyLevel(
|
||||||
level = level,
|
level = level,
|
||||||
color = MaterialTheme.colorScheme.outlineVariant,
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
selected =
|
selected =
|
||||||
if (event.id == noteId) {
|
if (event.id == noteId) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.outlineVariant
|
MaterialTheme.colorScheme.outlineVariant
|
||||||
},
|
},
|
||||||
).clickable {
|
).clickable {
|
||||||
onNavigateToThread(event.id)
|
onNavigateToThread(event.id)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
NoteCard(
|
NoteCard(
|
||||||
note = event.toNoteDisplayData(localCache),
|
note = event.toNoteDisplayData(localCache),
|
||||||
localCache = localCache,
|
|
||||||
onAuthorClick = onNavigateToProfile,
|
|
||||||
onMentionClick = onNavigateToProfile,
|
|
||||||
)
|
|
||||||
if (account != null) {
|
|
||||||
val eventZaps = zapsByEvent[event.id] ?: emptyList()
|
|
||||||
NoteActionsRow(
|
|
||||||
event = event,
|
|
||||||
relayManager = relayManager,
|
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
account = account,
|
onAuthorClick = onNavigateToProfile,
|
||||||
nwcConnection = nwcConnection,
|
onMentionClick = onNavigateToProfile,
|
||||||
onReplyClick = { onReply(event) },
|
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||||
onZapFeedback = onZapFeedback,
|
onMediaClick = { urls, index, seekPos ->
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
zapCount = eventZaps.size,
|
.playVideo(urls[index], seekPos)
|
||||||
zapAmountSats = eventZaps.sumOf { it.amountSats },
|
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
zapReceipts = eventZaps,
|
.toggleFullscreen()
|
||||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
|
||||||
replyCount = repliesByEvent[event.id] ?: 0,
|
|
||||||
repostCount = repostsByEvent[event.id] ?: 0,
|
|
||||||
bookmarkList = bookmarkList,
|
|
||||||
isBookmarked = bookmarkedEventIds.contains(event.id),
|
|
||||||
onBookmarkChanged = { newList ->
|
|
||||||
bookmarkList = newList
|
|
||||||
val pubIds =
|
|
||||||
newList
|
|
||||||
.publicBookmarks()
|
|
||||||
.filterIsInstance<EventBookmark>()
|
|
||||||
.map { it.eventId }
|
|
||||||
.toSet()
|
|
||||||
bookmarkedEventIds = pubIds
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if (account != null) {
|
||||||
|
val eventZaps = zapsByEvent[event.id] ?: emptyList()
|
||||||
|
NoteActionsRow(
|
||||||
|
event = event,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
onReplyClick = { onReply(event) },
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
zapCount = eventZaps.size,
|
||||||
|
zapAmountSats = eventZaps.sumOf { it.amountSats },
|
||||||
|
zapReceipts = eventZaps,
|
||||||
|
reactionCount = reactionsByEvent[event.id] ?: 0,
|
||||||
|
replyCount = repliesByEvent[event.id] ?: 0,
|
||||||
|
repostCount = repostsByEvent[event.id] ?: 0,
|
||||||
|
bookmarkList = bookmarkList,
|
||||||
|
isBookmarked = bookmarkedEventIds.contains(event.id),
|
||||||
|
onBookmarkChanged = { newList ->
|
||||||
|
bookmarkList = newList
|
||||||
|
val pubIds =
|
||||||
|
newList
|
||||||
|
.publicBookmarks()
|
||||||
|
.filterIsInstance<EventBookmark>()
|
||||||
|
.map { it.eventId }
|
||||||
|
.toSet()
|
||||||
|
bookmarkedEventIds = pubIds
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
HorizontalDivider(thickness = 1.dp)
|
||||||
}
|
}
|
||||||
HorizontalDivider(thickness = 1.dp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty state for no replies
|
// Empty state for no replies
|
||||||
if (replyEvents.isEmpty() && repliesEoseReceived) {
|
if (replyEvents.isEmpty() && repliesEoseReceived) {
|
||||||
item {
|
item {
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
Text(
|
Text(
|
||||||
"No replies yet",
|
"No replies yet",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (replyEvents.isEmpty() && !repliesEoseReceived) {
|
} else if (replyEvents.isEmpty() && !repliesEoseReceived) {
|
||||||
item {
|
item {
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
Text(
|
Text(
|
||||||
"Loading replies...",
|
"Loading replies...",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // end Column
|
||||||
|
|
||||||
|
// Lightbox overlay
|
||||||
|
val lb = lightboxState
|
||||||
|
if (lb != null) {
|
||||||
|
LightboxOverlay(
|
||||||
|
urls = lb.urls,
|
||||||
|
initialIndex = lb.index,
|
||||||
|
initialSeekPosition = lb.seekPosition,
|
||||||
|
initialFullscreen = lb.fullscreen,
|
||||||
|
onDismiss = { lightboxState = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
} // end Box
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+4
-1
@@ -714,7 +714,10 @@ fun UserProfileScreen(
|
|||||||
lightboxState = LightboxState(urls, index)
|
lightboxState = LightboxState(urls, index)
|
||||||
},
|
},
|
||||||
onMediaClick = { urls, index, seekPos ->
|
onMediaClick = { urls, index, seekPos ->
|
||||||
lightboxState = LightboxState(urls, index, seekPos, fullscreen = true)
|
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
|
.playVideo(urls[index], seekPos)
|
||||||
|
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
|
.toggleFullscreen()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
-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.Slider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
|
||||||
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Audio-only player using VLCJ with pooled audio players.
|
|
||||||
* Uses VlcjPlayerPool's shared audio factory instead of creating one per instance.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioPlayer(
|
fun AudioPlayer(
|
||||||
url: String,
|
url: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var isPlaying by remember { mutableStateOf(false) }
|
val audioState by GlobalMediaPlayer.audioState.collectAsState()
|
||||||
var position by remember { mutableFloatStateOf(0f) }
|
val isActiveAudio = audioState.url == url
|
||||||
var duration by remember { mutableLongStateOf(0L) }
|
|
||||||
var currentTime by remember { mutableLongStateOf(0L) }
|
|
||||||
var vlcAvailable by remember { mutableStateOf(true) }
|
|
||||||
var player by remember { mutableStateOf<MediaPlayer?>(null) }
|
|
||||||
|
|
||||||
DisposableEffect(url) {
|
val isPlaying = if (isActiveAudio) audioState.isPlaying else false
|
||||||
val mp = VlcjPlayerPool.acquireAudioPlayer()
|
val position = if (isActiveAudio) audioState.position else 0f
|
||||||
if (mp == null) {
|
val duration = if (isActiveAudio) audioState.duration else 0L
|
||||||
vlcAvailable = false
|
val currentTime = if (isActiveAudio) audioState.currentTime else 0L
|
||||||
return@DisposableEffect onDispose {}
|
|
||||||
}
|
|
||||||
|
|
||||||
val listener =
|
|
||||||
object : MediaPlayerEventAdapter() {
|
|
||||||
override fun playing(mediaPlayer: MediaPlayer) {
|
|
||||||
isPlaying = true
|
|
||||||
duration = mediaPlayer.status().length()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun paused(mediaPlayer: MediaPlayer) {
|
|
||||||
isPlaying = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopped(mediaPlayer: MediaPlayer) {
|
|
||||||
isPlaying = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun positionChanged(
|
|
||||||
mediaPlayer: MediaPlayer,
|
|
||||||
newPosition: Float,
|
|
||||||
) {
|
|
||||||
position = newPosition
|
|
||||||
currentTime = (newPosition * duration).toLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun finished(mediaPlayer: MediaPlayer) {
|
|
||||||
isPlaying = false
|
|
||||||
position = 0f
|
|
||||||
currentTime = 0L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mp.events().addMediaPlayerEventListener(listener)
|
|
||||||
player = mp
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
player = null
|
|
||||||
mp.events().removeMediaPlayerEventListener(listener)
|
|
||||||
VlcjPlayerPool.releaseAudioPlayer(mp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position polling
|
|
||||||
LaunchedEffect(isPlaying) {
|
|
||||||
while (isPlaying) {
|
|
||||||
delay(500)
|
|
||||||
player?.let {
|
|
||||||
position = it.status().position()
|
|
||||||
currentTime = it.status().time()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!vlcAvailable) {
|
|
||||||
Text(
|
|
||||||
"Audio: $url (install VLC to play)",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -157,16 +77,10 @@ fun AudioPlayer(
|
|||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
player?.let { p ->
|
if (isActiveAudio) {
|
||||||
if (isPlaying) {
|
GlobalMediaPlayer.toggleAudioPlayPause()
|
||||||
p.controls().pause()
|
} else {
|
||||||
} else {
|
GlobalMediaPlayer.playAudio(url)
|
||||||
if (position <= 0f && !p.status().isPlaying) {
|
|
||||||
p.media().play(url)
|
|
||||||
} else {
|
|
||||||
p.controls().play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
@@ -185,7 +99,7 @@ fun AudioPlayer(
|
|||||||
|
|
||||||
Slider(
|
Slider(
|
||||||
value = position,
|
value = position,
|
||||||
onValueChange = { player?.controls()?.setPosition(it) },
|
onValueChange = { GlobalMediaPlayer.seekAudio(it) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+39
-206
@@ -31,13 +31,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -45,27 +42,12 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache
|
import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache
|
||||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jetbrains.skia.Bitmap
|
|
||||||
import org.jetbrains.skia.ColorAlphaType
|
|
||||||
import org.jetbrains.skia.ImageInfo
|
|
||||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
|
||||||
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
|
|
||||||
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
|
|
||||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat
|
|
||||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
|
|
||||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
|
|
||||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.util.UUID
|
|
||||||
import org.jetbrains.skia.Image as SkiaImage
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DesktopVideoPlayer(
|
fun DesktopVideoPlayer(
|
||||||
@@ -78,29 +60,18 @@ fun DesktopVideoPlayer(
|
|||||||
onViewModeChange: ((ViewMode) -> Unit)? = null,
|
onViewModeChange: ((ViewMode) -> Unit)? = null,
|
||||||
trailingControls: @Composable (() -> Unit)? = null,
|
trailingControls: @Composable (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
var frame by remember { mutableStateOf<ImageBitmap?>(null) }
|
// Check if this URL is the active video
|
||||||
var isPlaying by remember { mutableStateOf(false) }
|
val videoState by GlobalMediaPlayer.videoState.collectAsState()
|
||||||
var isBuffering by remember { mutableStateOf(false) }
|
val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState()
|
||||||
var position by remember { mutableFloatStateOf(0f) }
|
val isActiveVideo = videoState.url == url
|
||||||
var duration by remember { mutableLongStateOf(0L) }
|
|
||||||
var currentTime by remember { mutableLongStateOf(0L) }
|
|
||||||
var aspectRatio by remember { mutableFloatStateOf(16f / 9f) }
|
|
||||||
var vlcAvailable by remember { mutableStateOf(true) }
|
|
||||||
var player by remember { mutableStateOf<EmbeddedMediaPlayer?>(null) }
|
|
||||||
var volume by remember { mutableIntStateOf(100) }
|
|
||||||
var isMuted by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Unique ID for single-player enforcement
|
// Thumbnail for inactive videos
|
||||||
val playerId = remember { UUID.randomUUID().toString() }
|
|
||||||
|
|
||||||
// Lazy activation — don't touch VLC until user clicks play (or autoPlay)
|
|
||||||
var activated by remember { mutableStateOf(autoPlay) }
|
|
||||||
|
|
||||||
// Load thumbnail when not activated, with retry on failure
|
|
||||||
var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) }
|
var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) }
|
||||||
LaunchedEffect(url, activated) {
|
var aspectRatio by remember { mutableFloatStateOf(16f / 9f) }
|
||||||
if (!activated && thumbnail == null) {
|
|
||||||
// Retry up to 3 times with increasing delay
|
// Load thumbnail when not active
|
||||||
|
LaunchedEffect(url, isActiveVideo) {
|
||||||
|
if (!isActiveVideo && thumbnail == null) {
|
||||||
for (attempt in 1..3) {
|
for (attempt in 1..3) {
|
||||||
val result = VideoThumbnailCache.getThumbnail(url)
|
val result = VideoThumbnailCache.getThumbnail(url)
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
@@ -112,153 +83,24 @@ fun DesktopVideoPlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pause when another player becomes active
|
// Auto-play on mount if requested
|
||||||
val activeId by ActiveMediaManager.activeId.collectAsState()
|
LaunchedEffect(url, autoPlay) {
|
||||||
LaunchedEffect(activeId) {
|
if (autoPlay) {
|
||||||
if (activeId != null && activeId != playerId && isPlaying) {
|
GlobalMediaPlayer.playVideo(url, initialSeekPosition)
|
||||||
player?.controls()?.pause()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up player off the UI thread when activated
|
// Sync aspect ratio from global state when active
|
||||||
LaunchedEffect(url, activated) {
|
if (isActiveVideo && videoState.aspectRatio != 16f / 9f) {
|
||||||
if (!activated) return@LaunchedEffect
|
aspectRatio = videoState.aspectRatio
|
||||||
isBuffering = true
|
|
||||||
|
|
||||||
val acquired =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
if (!VlcjPlayerPool.init()) return@withContext null
|
|
||||||
VlcjPlayerPool.acquire()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (acquired == null) {
|
|
||||||
vlcAvailable = false
|
|
||||||
isBuffering = false
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
var skBitmap: Bitmap? = null
|
|
||||||
var pixelBytes: ByteArray? = null
|
|
||||||
var didSeek = initialSeekPosition <= 0f
|
|
||||||
|
|
||||||
val bufferFormatCallback =
|
|
||||||
object : BufferFormatCallback {
|
|
||||||
override fun getBufferFormat(
|
|
||||||
sourceWidth: Int,
|
|
||||||
sourceHeight: Int,
|
|
||||||
): BufferFormat {
|
|
||||||
if (sourceHeight > 0) {
|
|
||||||
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat()
|
|
||||||
}
|
|
||||||
val bmp = Bitmap()
|
|
||||||
bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL))
|
|
||||||
skBitmap = bmp
|
|
||||||
pixelBytes = ByteArray(sourceWidth * sourceHeight * 4)
|
|
||||||
return RV32BufferFormat(sourceWidth, sourceHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
val renderCallback =
|
|
||||||
RenderCallback { _, nativeBuffers, _ ->
|
|
||||||
val bmp = skBitmap ?: return@RenderCallback
|
|
||||||
val bytes = pixelBytes ?: return@RenderCallback
|
|
||||||
val buffer = nativeBuffers[0]
|
|
||||||
buffer.rewind()
|
|
||||||
buffer.get(bytes)
|
|
||||||
bmp.installPixels(bytes)
|
|
||||||
frame = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
|
|
||||||
}
|
|
||||||
|
|
||||||
val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
|
|
||||||
acquired.videoSurface().set(surface)
|
|
||||||
|
|
||||||
acquired.events().addMediaPlayerEventListener(
|
|
||||||
object : MediaPlayerEventAdapter() {
|
|
||||||
override fun playing(mediaPlayer: MediaPlayer) {
|
|
||||||
isPlaying = true
|
|
||||||
isBuffering = false
|
|
||||||
duration = mediaPlayer.status().length()
|
|
||||||
// Seek to initial position on first play
|
|
||||||
if (!didSeek) {
|
|
||||||
didSeek = true
|
|
||||||
mediaPlayer.controls().setPosition(initialSeekPosition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun paused(mediaPlayer: MediaPlayer) {
|
|
||||||
isPlaying = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopped(mediaPlayer: MediaPlayer) {
|
|
||||||
isPlaying = false
|
|
||||||
isBuffering = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun buffering(
|
|
||||||
mediaPlayer: MediaPlayer,
|
|
||||||
newCache: Float,
|
|
||||||
) {
|
|
||||||
isBuffering = newCache < 100f
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun positionChanged(
|
|
||||||
mediaPlayer: MediaPlayer,
|
|
||||||
newPosition: Float,
|
|
||||||
) {
|
|
||||||
position = newPosition
|
|
||||||
currentTime = (newPosition * duration).toLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun finished(mediaPlayer: MediaPlayer) {
|
|
||||||
isPlaying = false
|
|
||||||
isBuffering = false
|
|
||||||
position = 0f
|
|
||||||
currentTime = 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun error(mediaPlayer: MediaPlayer) {
|
|
||||||
isBuffering = false
|
|
||||||
println("VLC: playback error for $url")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
player = acquired
|
|
||||||
ActiveMediaManager.activate(playerId)
|
|
||||||
acquired.media().play(url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up player on leave or URL change
|
if (!VlcjPlayerPool.isAvailable() && VlcjPlayerPool.init().not()) {
|
||||||
DisposableEffect(url) {
|
|
||||||
onDispose {
|
|
||||||
ActiveMediaManager.deactivate(playerId)
|
|
||||||
player?.let { p ->
|
|
||||||
VlcjPlayerPool.release(p)
|
|
||||||
player = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position polling (VLCJ events sometimes miss updates)
|
|
||||||
LaunchedEffect(isPlaying) {
|
|
||||||
while (isPlaying) {
|
|
||||||
delay(500)
|
|
||||||
player?.let {
|
|
||||||
position = it.status().position()
|
|
||||||
currentTime = it.status().time()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!vlcAvailable) {
|
|
||||||
VlcNotAvailableMessage(url, modifier)
|
VlcNotAvailableMessage(url, modifier)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
BoxWithConstraints(modifier = modifier) {
|
BoxWithConstraints(modifier = modifier) {
|
||||||
// Calculate height from aspect ratio, clamped to max constraints
|
|
||||||
val desiredHeight = maxWidth / aspectRatio
|
val desiredHeight = maxWidth / aspectRatio
|
||||||
val constrainedHeight = if (constraints.hasBoundedHeight) minOf(desiredHeight, maxHeight) else desiredHeight
|
val constrainedHeight = if (constraints.hasBoundedHeight) minOf(desiredHeight, maxHeight) else desiredHeight
|
||||||
|
|
||||||
@@ -273,7 +115,7 @@ fun DesktopVideoPlayer(
|
|||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
val displayBitmap = frame ?: thumbnail
|
val displayBitmap: ImageBitmap? = if (isActiveVideo) videoFrame ?: thumbnail else thumbnail
|
||||||
displayBitmap?.let { bitmap ->
|
displayBitmap?.let { bitmap ->
|
||||||
Image(
|
Image(
|
||||||
bitmap = bitmap,
|
bitmap = bitmap,
|
||||||
@@ -287,47 +129,38 @@ fun DesktopVideoPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
VideoControls(
|
VideoControls(
|
||||||
isPlaying = isPlaying,
|
isPlaying = if (isActiveVideo) videoState.isPlaying else false,
|
||||||
isBuffering = isBuffering,
|
isBuffering = if (isActiveVideo) videoState.isBuffering else false,
|
||||||
position = position,
|
position = if (isActiveVideo) videoState.position else 0f,
|
||||||
duration = duration,
|
duration = if (isActiveVideo) videoState.duration else 0L,
|
||||||
currentTime = currentTime,
|
currentTime = if (isActiveVideo) videoState.currentTime else 0L,
|
||||||
volume = volume,
|
volume = if (isActiveVideo) videoState.volume else 100,
|
||||||
isMuted = isMuted,
|
isMuted = if (isActiveVideo) videoState.isMuted else false,
|
||||||
viewMode = viewMode,
|
viewMode = viewMode,
|
||||||
onPlayPause = {
|
onPlayPause = {
|
||||||
val p = player
|
if (isActiveVideo) {
|
||||||
if (p != null) {
|
GlobalMediaPlayer.toggleVideoPlayPause()
|
||||||
if (isPlaying) {
|
|
||||||
p.controls().pause()
|
|
||||||
} else {
|
|
||||||
ActiveMediaManager.activate(playerId)
|
|
||||||
if (position <= 0f && !p.status().isPlaying) {
|
|
||||||
p.media().play(url)
|
|
||||||
isBuffering = true
|
|
||||||
} else {
|
|
||||||
p.controls().play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// First play — activate lazy init
|
GlobalMediaPlayer.playVideo(url, initialSeekPosition)
|
||||||
activated = true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSeek = { pos ->
|
onSeek = { pos ->
|
||||||
player?.controls()?.setPosition(pos)
|
if (isActiveVideo) {
|
||||||
|
GlobalMediaPlayer.seekVideo(pos)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onVolumeChange = { vol ->
|
onVolumeChange = { vol ->
|
||||||
volume = vol
|
GlobalMediaPlayer.setVideoVolume(vol)
|
||||||
player?.audio()?.setVolume(vol)
|
|
||||||
},
|
},
|
||||||
onMuteToggle = {
|
onMuteToggle = {
|
||||||
isMuted = !isMuted
|
GlobalMediaPlayer.toggleVideoMute()
|
||||||
player?.audio()?.isMute = isMuted
|
|
||||||
},
|
},
|
||||||
onFullscreen =
|
onFullscreen =
|
||||||
if (onFullscreen != null) {
|
if (onFullscreen != null) {
|
||||||
{ onFullscreen(position) }
|
{
|
||||||
|
val pos = if (isActiveVideo) videoState.position else 0f
|
||||||
|
onFullscreen(pos)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
|||||||
+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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Error
|
import androidx.compose.material.icons.filled.Error
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
@@ -71,6 +73,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.isCtrlPressed
|
import androidx.compose.ui.input.key.isCtrlPressed
|
||||||
|
import androidx.compose.ui.input.key.isMetaPressed
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
@@ -231,7 +234,7 @@ fun LightboxOverlay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Key.S -> {
|
Key.S -> {
|
||||||
if (event.isCtrlPressed) {
|
if (event.isCtrlPressed || event.isMetaPressed) {
|
||||||
triggerSave()
|
triggerSave()
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
@@ -247,13 +250,19 @@ fun LightboxOverlay(
|
|||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
) {
|
) {
|
||||||
// Click on backdrop doesn't close — use X button or Esc
|
if (viewMode != ViewMode.FULLSCREEN) onDismiss()
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// Main content — video or image
|
// Main content — video or image
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
Box(
|
Box(
|
||||||
modifier = contentModifier,
|
modifier =
|
||||||
|
contentModifier.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) {
|
||||||
|
// Consume clicks so backdrop dismiss doesn't fire
|
||||||
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
DesktopVideoPlayer(
|
DesktopVideoPlayer(
|
||||||
@@ -397,6 +406,36 @@ fun LightboxOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close button (top-left) — hidden in fullscreen
|
||||||
|
if (viewMode != ViewMode.FULLSCREEN) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image counter (bottom-center) — hidden in fullscreen
|
||||||
|
if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) {
|
||||||
|
Text(
|
||||||
|
text = "${currentIndex + 1} / ${urls.size}",
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(16.dp)
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Navigation arrows — hidden in fullscreen
|
// Navigation arrows — hidden in fullscreen
|
||||||
if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) {
|
if (urls.size > 1 && viewMode != ViewMode.FULLSCREEN) {
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
|
|||||||
+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 {
|
Column {
|
||||||
// Images
|
// Images
|
||||||
for ((index, url) in imageUrls.withIndex()) {
|
for ((index, url) in imageUrls.withIndex()) {
|
||||||
AsyncImage(
|
val imageModifier =
|
||||||
model = url,
|
Modifier
|
||||||
contentDescription = title,
|
.fillMaxWidth()
|
||||||
modifier =
|
.heightIn(max = 500.dp)
|
||||||
Modifier
|
.clip(
|
||||||
.fillMaxWidth()
|
if (index == 0 && title == null && description.isBlank()) {
|
||||||
.heightIn(max = 500.dp)
|
RoundedCornerShape(8.dp)
|
||||||
.clip(
|
} else if (index == 0) {
|
||||||
if (index == 0 && title == null && description.isBlank()) {
|
RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
|
||||||
RoundedCornerShape(8.dp)
|
} else {
|
||||||
} else if (index == 0) {
|
RoundedCornerShape(0.dp)
|
||||||
RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
|
},
|
||||||
} else {
|
).then(
|
||||||
RoundedCornerShape(0.dp)
|
if (onImageClick != null) {
|
||||||
},
|
Modifier.clickable { onImageClick(imageUrls, index) }
|
||||||
).then(
|
} else {
|
||||||
if (onImageClick != null) {
|
Modifier
|
||||||
Modifier.clickable { onImageClick(imageUrls, index) }
|
},
|
||||||
} else {
|
)
|
||||||
Modifier
|
if (isAnimatedGifUrl(url)) {
|
||||||
},
|
AnimatedGifImage(
|
||||||
),
|
url = url,
|
||||||
contentScale = ContentScale.FillWidth,
|
contentDescription = title,
|
||||||
)
|
modifier = imageModifier,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AsyncImage(
|
||||||
|
model = url,
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = imageModifier,
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title + description below images
|
// Title + description below images
|
||||||
|
|||||||
+1
-1
@@ -194,7 +194,7 @@ fun VideoControls(
|
|||||||
Slider(
|
Slider(
|
||||||
value = volume / 100f,
|
value = volume / 100f,
|
||||||
onValueChange = { onVolumeChange((it * 100).toInt()) },
|
onValueChange = { onVolumeChange((it * 100).toInt()) },
|
||||||
modifier = Modifier.width(80.dp),
|
modifier = Modifier.width(240.dp),
|
||||||
colors =
|
colors =
|
||||||
SliderDefaults.colors(
|
SliderDefaults.colors(
|
||||||
thumbColor = Color.White,
|
thumbColor = Color.White,
|
||||||
|
|||||||
+36
-14
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.desktop.ui.media
|
package com.vitorpamplona.amethyst.desktop.ui.media
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -52,6 +53,17 @@ fun ZoomableImage(
|
|||||||
modifier
|
modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onDoubleTap = {
|
||||||
|
scale = 1f
|
||||||
|
offsetX = 0f
|
||||||
|
offsetY = 0f
|
||||||
|
},
|
||||||
|
onTap = {
|
||||||
|
// Consume single taps so they don't propagate to backdrop
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.pointerInput(Unit) {
|
||||||
awaitPointerEventScope {
|
awaitPointerEventScope {
|
||||||
while (true) {
|
while (true) {
|
||||||
val event = awaitPointerEvent()
|
val event = awaitPointerEvent()
|
||||||
@@ -77,19 +89,29 @@ fun ZoomableImage(
|
|||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
val imageModifier =
|
||||||
model = url,
|
Modifier
|
||||||
contentDescription = null,
|
.fillMaxSize()
|
||||||
modifier =
|
.graphicsLayer(
|
||||||
Modifier
|
scaleX = scale,
|
||||||
.fillMaxSize()
|
scaleY = scale,
|
||||||
.graphicsLayer(
|
translationX = offsetX,
|
||||||
scaleX = scale,
|
translationY = offsetY,
|
||||||
scaleY = scale,
|
)
|
||||||
translationX = offsetX,
|
if (isAnimatedGifUrl(url)) {
|
||||||
translationY = offsetY,
|
AnimatedGifImage(
|
||||||
),
|
url = url,
|
||||||
contentScale = ContentScale.Fit,
|
contentDescription = null,
|
||||||
)
|
modifier = imageModifier,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AsyncImage(
|
||||||
|
model = url,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = imageModifier,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-46
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui.note
|
|||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -57,9 +58,11 @@ import com.vitorpamplona.amethyst.commons.richtext.Urls
|
|||||||
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
|
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
|
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||||
@@ -155,60 +158,69 @@ fun NoteCard(
|
|||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
),
|
),
|
||||||
onClick = onClick ?: {},
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
Row(
|
// Header + text area — clickable to navigate to thread
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Column(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
modifier =
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
if (onClick != null) {
|
||||||
|
Modifier.clickable { onClick() }
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
// Author with avatar
|
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier =
|
|
||||||
if (onAuthorClick != null) {
|
|
||||||
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
UserAvatar(
|
// Author with avatar
|
||||||
userHex = note.pubKeyHex,
|
Row(
|
||||||
pictureUrl = note.profilePictureUrl,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
size = 32.dp,
|
modifier =
|
||||||
contentDescription = "Profile picture of ${note.pubKeyDisplay}",
|
if (onAuthorClick != null) {
|
||||||
)
|
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userHex = note.pubKeyHex,
|
||||||
|
pictureUrl = note.profilePictureUrl,
|
||||||
|
size = 32.dp,
|
||||||
|
contentDescription = "Profile picture of ${note.pubKeyDisplay}",
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
Text(
|
Text(
|
||||||
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
|
text = note.createdAt.toTimeAgo(withDot = false),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timestamp
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
|
||||||
text = note.createdAt.toTimeAgo(withDot = false),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
if (strippedContent.isNotBlank()) {
|
||||||
|
RichTextContent(
|
||||||
if (strippedContent.isNotBlank()) {
|
content = strippedContent,
|
||||||
RichTextContent(
|
urls = strippedUrls,
|
||||||
content = strippedContent,
|
localCache = localCache,
|
||||||
urls = strippedUrls,
|
onMentionClick = onMentionClick,
|
||||||
localCache = localCache,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onMentionClick = onMentionClick,
|
)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
}
|
||||||
)
|
} // end clickable header+text column
|
||||||
}
|
|
||||||
|
|
||||||
// Inline images
|
// Inline images
|
||||||
if (imageUrls.isNotEmpty()) {
|
if (imageUrls.isNotEmpty()) {
|
||||||
@@ -216,9 +228,7 @@ fun NoteCard(
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
for ((index, url) in imageUrls.withIndex()) {
|
for ((index, url) in imageUrls.withIndex()) {
|
||||||
AsyncImage(
|
Box(
|
||||||
model = url,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -231,8 +241,23 @@ fun NoteCard(
|
|||||||
Modifier
|
Modifier
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
contentScale = ContentScale.Fit,
|
) {
|
||||||
)
|
if (isAnimatedGifUrl(url)) {
|
||||||
|
AnimatedGifImage(
|
||||||
|
url = url,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AsyncImage(
|
||||||
|
model = url,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (url != imageUrls.last()) {
|
if (url != imageUrls.last()) {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-18
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui.settings
|
package com.vitorpamplona.amethyst.desktop.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -29,8 +30,6 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
@@ -40,12 +39,17 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.PlainTooltip
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TooltipBox
|
||||||
|
import androidx.compose.material3.TooltipDefaults
|
||||||
|
import androidx.compose.material3.rememberTooltipState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -106,15 +110,19 @@ fun MediaServerSettings(
|
|||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Server list
|
// Server list
|
||||||
LazyColumn(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
|
||||||
) {
|
) {
|
||||||
items(servers) { server ->
|
for (server in servers.toList()) {
|
||||||
ServerRow(
|
ServerRow(
|
||||||
server = server,
|
server = server,
|
||||||
status = serverStatuses[server] ?: ServerHealthCheck.ServerStatus.UNKNOWN,
|
status = serverStatuses[server] ?: ServerHealthCheck.ServerStatus.UNKNOWN,
|
||||||
isDefault = servers.indexOf(server) == 0,
|
isDefault = servers.indexOf(server) == 0,
|
||||||
|
onSetDefault = {
|
||||||
|
servers.remove(server)
|
||||||
|
servers.add(0, server)
|
||||||
|
onServersChanged(servers.toList())
|
||||||
|
},
|
||||||
onRemove = {
|
onRemove = {
|
||||||
servers.remove(server)
|
servers.remove(server)
|
||||||
serverStatuses.remove(server)
|
serverStatuses.remove(server)
|
||||||
@@ -152,7 +160,7 @@ fun MediaServerSettings(
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val url = newServerUrl.trim().removeSuffix("/")
|
val url = newServerUrl.trim().removeSuffix("/")
|
||||||
if (url.isNotBlank() && url !in servers) {
|
if (url.isNotBlank() && url !in servers && isValidServerUrl(url)) {
|
||||||
servers.add(url)
|
servers.add(url)
|
||||||
newServerUrl = ""
|
newServerUrl = ""
|
||||||
onServersChanged(servers.toList())
|
onServersChanged(servers.toList())
|
||||||
@@ -162,7 +170,7 @@ fun MediaServerSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = newServerUrl.isNotBlank(),
|
enabled = newServerUrl.isNotBlank() && isValidServerUrl(newServerUrl.trim()),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add")
|
Icon(Icons.Default.Add, contentDescription = "Add")
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
@@ -202,11 +210,23 @@ fun MediaServerSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isValidServerUrl(url: String): Boolean {
|
||||||
|
val trimmed = url.trim().removeSuffix("/")
|
||||||
|
return try {
|
||||||
|
val uri = java.net.URI(trimmed)
|
||||||
|
uri.scheme in listOf("https", "http") && uri.host != null && uri.host.contains(".")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ServerRow(
|
private fun ServerRow(
|
||||||
server: String,
|
server: String,
|
||||||
status: ServerHealthCheck.ServerStatus,
|
status: ServerHealthCheck.ServerStatus,
|
||||||
isDefault: Boolean,
|
isDefault: Boolean,
|
||||||
|
onSetDefault: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -221,17 +241,33 @@ private fun ServerRow(
|
|||||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Status indicator
|
// Status indicator with tooltip
|
||||||
Surface(
|
TooltipBox(
|
||||||
modifier = Modifier.size(12.dp),
|
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||||
shape = CircleShape,
|
tooltip = {
|
||||||
color =
|
PlainTooltip {
|
||||||
when (status) {
|
Text(
|
||||||
ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50)
|
when (status) {
|
||||||
ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336)
|
ServerHealthCheck.ServerStatus.ONLINE -> "Online"
|
||||||
ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E)
|
ServerHealthCheck.ServerStatus.OFFLINE -> "Offline — server unreachable"
|
||||||
},
|
ServerHealthCheck.ServerStatus.UNKNOWN -> "Checking..."
|
||||||
) {}
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
state = rememberTooltipState(),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
color =
|
||||||
|
when (status) {
|
||||||
|
ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50)
|
||||||
|
ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336)
|
||||||
|
ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E)
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
@@ -246,6 +282,13 @@ private fun ServerRow(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"Set as default",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.clickable { onSetDefault() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,604 @@
|
|||||||
|
# Blossom Protocol Research
|
||||||
|
|
||||||
|
**Date**: 2026-03-16
|
||||||
|
**Sources**: hzrd149/blossom GitHub (BUD specs), NIP-B7, Nostrify docs, Amethyst upstream codebase, Primal blog posts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Blossom (**Bl**obs **O**n **S**imple **S**erver**om**... or something) is a specification for HTTP endpoints that let users store binary blobs on publicly accessible servers. Blobs are content-addressed by their **SHA-256 hash**. Uses Nostr keypairs for identity and authorization.
|
||||||
|
|
||||||
|
**Two Nostr event kinds:**
|
||||||
|
- **Kind 24242** -- Authorization token (BUD-11)
|
||||||
|
- **Kind 10063** -- User's Blossom server list (BUD-03, NIP-B7)
|
||||||
|
|
||||||
|
**BUD index (BUD-00 through BUD-11):**
|
||||||
|
|
||||||
|
| BUD | Name | Status | Required |
|
||||||
|
|-----|------|--------|----------|
|
||||||
|
| 00 | BUD framework | - | - |
|
||||||
|
| 01 | Server requirements + blob retrieval | draft | mandatory |
|
||||||
|
| 02 | Upload + management | draft | optional |
|
||||||
|
| 03 | User server list (kind 10063) | draft | optional |
|
||||||
|
| 04 | Mirroring | draft | optional |
|
||||||
|
| 05 | Media optimization | draft | optional |
|
||||||
|
| 06 | Upload requirements (HEAD preflight) | draft | optional |
|
||||||
|
| 07 | Payment required (402) | draft | optional |
|
||||||
|
| 08 | NIP-94 file metadata tags | draft | optional |
|
||||||
|
| 09 | Blob report | draft | optional |
|
||||||
|
| 10 | Blossom URI scheme | draft | optional |
|
||||||
|
| 11 | Nostr authorization | draft | optional |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUD-01: Server Requirements + Blob Retrieval
|
||||||
|
|
||||||
|
**Status:** `draft` `mandatory`
|
||||||
|
|
||||||
|
### CORS
|
||||||
|
|
||||||
|
All responses MUST set `Access-Control-Allow-Origin: *`.
|
||||||
|
|
||||||
|
Preflight (`OPTIONS`) responses MUST also set:
|
||||||
|
```
|
||||||
|
Access-Control-Allow-Headers: Authorization, *
|
||||||
|
Access-Control-Allow-Methods: GET, HEAD, PUT, DELETE
|
||||||
|
```
|
||||||
|
|
||||||
|
MAY set `Access-Control-Max-Age: 86400` (cache 24h).
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
Any 4xx/5xx response MAY include `X-Reason` header with human-readable error message.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
All endpoints served from domain root. No path prefix.
|
||||||
|
|
||||||
|
#### GET /<sha256> -- Get Blob
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1
|
||||||
|
Host: cdn.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```http
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: application/pdf
|
||||||
|
Content-Length: 184292
|
||||||
|
|
||||||
|
<binary blob data>
|
||||||
|
```
|
||||||
|
|
||||||
|
- MUST accept optional file extension in URL (`.pdf`, `.png`, etc.)
|
||||||
|
- MUST return correct `Content-Type` regardless of extension
|
||||||
|
- MUST default to `application/octet-stream` if MIME unknown
|
||||||
|
- MAY require authorization (BUD-11)
|
||||||
|
|
||||||
|
**Proxying/Redirection:**
|
||||||
|
- 3xx redirects MUST redirect to URL containing same SHA-256 hash
|
||||||
|
- Destination MUST set `Access-Control-Allow-Origin: *`, `Content-Type`, `Content-Length`
|
||||||
|
|
||||||
|
**Range Requests:**
|
||||||
|
- Servers SHOULD support `Range` header (RFC 7233) on GET
|
||||||
|
- Signal via `Accept-Ranges: bytes` and `Content-Length` on HEAD
|
||||||
|
|
||||||
|
#### HEAD /<sha256> -- Has Blob
|
||||||
|
|
||||||
|
Identical to GET but MUST NOT return body. MUST return same `Content-Type` and `Content-Length` headers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUD-02: Upload + Management
|
||||||
|
|
||||||
|
**Status:** `draft` `optional`
|
||||||
|
|
||||||
|
### Blob Descriptor
|
||||||
|
|
||||||
|
The standard JSON response for all upload/mirror operations:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://cdn.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf",
|
||||||
|
"sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
|
||||||
|
"size": 184292,
|
||||||
|
"type": "application/pdf",
|
||||||
|
"uploaded": 1725105921
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `url` -- Public URL to `GET /<sha256>` endpoint **with file extension**
|
||||||
|
- `sha256` -- Hex-encoded SHA-256 of the blob
|
||||||
|
- `size` -- Size in bytes
|
||||||
|
- `type` -- MIME type (fallback `application/octet-stream`)
|
||||||
|
- `uploaded` -- Unix timestamp
|
||||||
|
|
||||||
|
MAY include: `magnet`, `infohash`, `ipfs`
|
||||||
|
|
||||||
|
### PUT /upload -- Upload Blob
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /upload HTTP/1.1
|
||||||
|
Host: cdn.example.com
|
||||||
|
Authorization: Nostr <base64url-encoded kind 24242 event>
|
||||||
|
Content-Type: image/png
|
||||||
|
Content-Length: 184292
|
||||||
|
X-SHA-256: b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553
|
||||||
|
|
||||||
|
<raw binary data>
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (success):
|
||||||
|
```http
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"url": "https://cdn.example.com/b167...553.png",
|
||||||
|
"sha256": "b167...553",
|
||||||
|
"size": 184292,
|
||||||
|
"type": "image/png",
|
||||||
|
"uploaded": 1725105921
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- Server MUST NOT modify the blob
|
||||||
|
- Server MUST compute SHA-256 over exact bytes received
|
||||||
|
- Client SHOULD include `Content-Type` and `Content-Length`
|
||||||
|
- Client MAY provide `X-SHA-256` header (hex lowercase)
|
||||||
|
- Server MAY use `X-SHA-256` for pre-upload rejection policies
|
||||||
|
- Success: 2xx with Blob Descriptor
|
||||||
|
- Failure: 4xx with error message
|
||||||
|
|
||||||
|
### GET /list/<pubkey> -- List Blobs (Unrecommended)
|
||||||
|
|
||||||
|
Optional. Returns JSON array of Blob Descriptors for a pubkey.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- `cursor` -- SHA-256 of last blob (cursor-based pagination)
|
||||||
|
- `limit` -- Max results
|
||||||
|
- `since`/`until` -- Filter by upload date (deprecated for pagination)
|
||||||
|
|
||||||
|
Sorted by `uploaded` descending.
|
||||||
|
|
||||||
|
### DELETE /<sha256> -- Delete Blob
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf HTTP/1.1
|
||||||
|
Host: cdn.example.com
|
||||||
|
Authorization: Nostr <base64url-encoded kind 24242 event>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Multiple `x` tags in auth token MUST NOT be interpreted as batch delete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUD-03: User Server List
|
||||||
|
|
||||||
|
**Kind 10063** (replaceable event).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 10063,
|
||||||
|
"tags": [
|
||||||
|
["server", "https://cdn.self.hosted"],
|
||||||
|
["server", "https://cdn.satellite.earth"],
|
||||||
|
["alt", "File servers used by the author"]
|
||||||
|
],
|
||||||
|
"content": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Tag order = priority. Most trusted/reliable first.
|
||||||
|
- Clients MUST upload to at least the first server in user's list.
|
||||||
|
- Clients MAY mirror to other listed servers via BUD-04.
|
||||||
|
|
||||||
|
**Discovery flow when URL breaks:**
|
||||||
|
1. Extract 64-char hex hash from broken URL
|
||||||
|
2. Fetch author's kind:10063 event
|
||||||
|
3. Try each listed server in order
|
||||||
|
4. Fall back to well-known servers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUD-04: Mirroring
|
||||||
|
|
||||||
|
**Status:** `draft` `optional`
|
||||||
|
|
||||||
|
### PUT /mirror -- Mirror Blob
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /mirror HTTP/1.1
|
||||||
|
Host: backup-server.example.com
|
||||||
|
Authorization: Nostr <base64url-encoded kind 24242 event>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"url": "https://cdn.satellite.earth/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: Blob Descriptor (same as upload).
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- Server downloads blob from provided URL
|
||||||
|
- Server SHOULD use `Content-Type` from origin server
|
||||||
|
- Server verifies downloaded blob hash matches `x` tag in auth token
|
||||||
|
- Returns 2xx + Blob Descriptor on success, 4xx on failure
|
||||||
|
|
||||||
|
**Typical flow:**
|
||||||
|
1. Client uploads to Server A, gets Blob Descriptor with URL
|
||||||
|
2. Client sends URL to Server B's `/mirror` with same upload auth token
|
||||||
|
3. Server B downloads from Server A
|
||||||
|
4. Server B verifies hash matches `x` tag
|
||||||
|
5. Server B returns Blob Descriptor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUD-05: Media Optimization
|
||||||
|
|
||||||
|
**Status:** `draft` `optional`
|
||||||
|
|
||||||
|
### PUT /media -- Optimized Upload
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /media HTTP/1.1
|
||||||
|
Host: trusted-server.example.com
|
||||||
|
Authorization: Nostr <base64url-encoded kind 24242 event with t=media>
|
||||||
|
Content-Type: image/png
|
||||||
|
Content-Length: 4194304
|
||||||
|
|
||||||
|
<raw binary data>
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: Blob Descriptor -- but hash will differ from input because server transforms the file.
|
||||||
|
|
||||||
|
Key differences from `/upload`:
|
||||||
|
- Server MAY modify/optimize the blob (strip EXIF, compress, transcode)
|
||||||
|
- The returned SHA-256 will be of the **optimized** blob, not the original
|
||||||
|
- Client has NO control over optimization process
|
||||||
|
- `t` tag in auth event must be `media` (not `upload`)
|
||||||
|
|
||||||
|
### HEAD /media
|
||||||
|
|
||||||
|
Same as HEAD /upload (BUD-06) but for the media endpoint.
|
||||||
|
|
||||||
|
### Client Implementation Pattern
|
||||||
|
|
||||||
|
1. User selects a "trusted processing" server
|
||||||
|
2. Client uploads original media to `/media` on trusted server
|
||||||
|
3. Gets back optimized blob descriptor (new hash)
|
||||||
|
4. Client signs new upload auth for the optimized hash
|
||||||
|
5. Calls `/mirror` on other servers to distribute the optimized blob
|
||||||
|
|
||||||
|
This is what Primal does -- all Primal 2.2+ apps use `/media` by default, strips metadata, then mirrors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUD-06: Upload Requirements (HEAD Preflight)
|
||||||
|
|
||||||
|
**Status:** `draft` `optional`
|
||||||
|
|
||||||
|
### HEAD /upload -- Pre-flight Check
|
||||||
|
|
||||||
|
Client sends blob metadata, server says yes/no before actual upload.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```http
|
||||||
|
HEAD /upload HTTP/1.1
|
||||||
|
Host: cdn.example.com
|
||||||
|
X-Content-Type: application/pdf
|
||||||
|
X-Content-Length: 184292
|
||||||
|
X-SHA-256: 88a74d0b866c8ba79251a11fe5ac807839226870e77355f02eaf68b156522576
|
||||||
|
Authorization: Nostr <base64url-encoded event>
|
||||||
|
```
|
||||||
|
|
||||||
|
Success:
|
||||||
|
```http
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Failure examples:
|
||||||
|
```http
|
||||||
|
HTTP/1.1 400 Bad Request
|
||||||
|
X-Reason: Invalid X-SHA-256 header format. Expected a string.
|
||||||
|
|
||||||
|
HTTP/1.1 401 Unauthorized
|
||||||
|
X-Reason: Authorization required for uploading video files.
|
||||||
|
|
||||||
|
HTTP/1.1 403 Forbidden
|
||||||
|
X-Reason: SHA-256 hash banned.
|
||||||
|
|
||||||
|
HTTP/1.1 411 Length Required
|
||||||
|
X-Reason: Missing X-Content-Length header.
|
||||||
|
|
||||||
|
HTTP/1.1 413 Content Too Large
|
||||||
|
X-Reason: File too large. Max allowed size is 100MB.
|
||||||
|
|
||||||
|
HTTP/1.1 415 Unsupported Media Type
|
||||||
|
X-Reason: Unsupported file type.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Uses `X-Content-Type`, `X-Content-Length`, `X-SHA-256` headers (not standard `Content-*`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUD-07: Payment Required
|
||||||
|
|
||||||
|
Servers MAY return `402 Payment Required` with payment method headers:
|
||||||
|
|
||||||
|
```http
|
||||||
|
HTTP/1.1 402 Payment Required
|
||||||
|
X-Cashu: "<NUT-24 cashu token>"
|
||||||
|
X-Lightning: "<BOLT-11 invoice>"
|
||||||
|
```
|
||||||
|
|
||||||
|
After payment, client retries with proof:
|
||||||
|
- Cashu: serialized `cashuB` token per NUT-24
|
||||||
|
- Lightning: preimage of the BOLT-11 payment
|
||||||
|
|
||||||
|
HEAD requests inform about cost but should not be retried with payment; proceed to PUT/GET after paying.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUD-08: NIP-94 File Metadata Tags
|
||||||
|
|
||||||
|
Servers MAY include a `nip94` field in Blob Descriptor responses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://cdn.example.com/b167...553.pdf",
|
||||||
|
"sha256": "b167...553",
|
||||||
|
"size": 184292,
|
||||||
|
"type": "application/pdf",
|
||||||
|
"uploaded": 1725105921,
|
||||||
|
"nip94": [
|
||||||
|
["url", "https://cdn.example.com/b167...553.pdf"],
|
||||||
|
["m", "application/pdf"],
|
||||||
|
["x", "b167...553"],
|
||||||
|
["size", "184292"],
|
||||||
|
["magnet", "magnet:?xt=urn:btih:..."],
|
||||||
|
["i", "infohash-here"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Follows NIP-94 tag format as KV pairs. Allows clients to get standardized metadata without separate requests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUD-09: Blob Report
|
||||||
|
|
||||||
|
### PUT /report
|
||||||
|
|
||||||
|
Body: signed NIP-56 report event (kind 1984):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 1984,
|
||||||
|
"content": "This blob contains illegal content",
|
||||||
|
"tags": [
|
||||||
|
["x", "<sha256>", "illegal"],
|
||||||
|
["p", "<uploader-pubkey>"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Server maintains records for operator review. Optionally authorizes trusted moderators for autonomous removal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUD-10: Blossom URI Scheme
|
||||||
|
|
||||||
|
Format:
|
||||||
|
```
|
||||||
|
blossom:<sha256>.<ext>[?param1=value1¶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