feat(media): improve video player pool, thumbnail extraction, and NoteCard layout

Harden VLC player pool with dedicated thumbnail acquisition, better error
logging, and defensive buffer checks. Simplify lightbox/video controls,
fix NoteCard media sizing across feed, thread, bookmarks, and search screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-18 09:24:00 +02:00
parent d70b13ef33
commit 6e1315a4fa
12 changed files with 306 additions and 112 deletions
@@ -58,24 +58,25 @@ object VideoThumbnailCache {
} }
private fun extractFirstFrame(url: String): ImageBitmap? { private fun extractFirstFrame(url: String): ImageBitmap? {
if (!VlcjPlayerPool.init()) return null if (!VlcjPlayerPool.init()) {
val player = VlcjPlayerPool.acquire() ?: return null println("VLC thumbnail: init failed for $url")
return null
}
val player = VlcjPlayerPool.acquireForThumbnail()
if (player == null) {
println("VLC thumbnail: pool exhausted for $url")
return null
}
var result: ImageBitmap? = null var result: ImageBitmap? = null
val latch = CountDownLatch(1) val latch = CountDownLatch(1)
var aspectRatio = 16f / 9f
val bufferFormatCallback = val bufferFormatCallback =
object : BufferFormatCallback { object : BufferFormatCallback {
override fun getBufferFormat( override fun getBufferFormat(
sourceWidth: Int, sourceWidth: Int,
sourceHeight: Int, sourceHeight: Int,
): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat { ): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat = RV32BufferFormat(sourceWidth, sourceHeight)
if (sourceHeight > 0) {
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat()
}
return RV32BufferFormat(sourceWidth, sourceHeight)
}
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {} override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
} }
@@ -84,8 +85,10 @@ object VideoThumbnailCache {
RenderCallback { _, nativeBuffers, bufferFormat -> RenderCallback { _, nativeBuffers, bufferFormat ->
if (result != null) return@RenderCallback if (result != null) return@RenderCallback
try { try {
if (nativeBuffers.isEmpty()) return@RenderCallback
val w = bufferFormat.width val w = bufferFormat.width
val h = bufferFormat.height val h = bufferFormat.height
if (w <= 0 || h <= 0) return@RenderCallback
val bmp = Bitmap() val bmp = Bitmap()
bmp.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL)) bmp.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
val bytes = ByteArray(w * h * 4) val bytes = ByteArray(w * h * 4)
@@ -95,13 +98,15 @@ object VideoThumbnailCache {
bmp.installPixels(bytes) bmp.installPixels(bytes)
result = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() result = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
latch.countDown() latch.countDown()
} catch (_: Exception) { } catch (e: Exception) {
println("VLC thumbnail: render error for $url${e.message}")
latch.countDown() latch.countDown()
} }
} }
val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback) val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
if (surface == null) { if (surface == null) {
println("VLC thumbnail: surface creation failed for $url")
VlcjPlayerPool.release(player) VlcjPlayerPool.release(player)
return null return null
} }
@@ -113,6 +118,7 @@ object VideoThumbnailCache {
player.events().addMediaPlayerEventListener( player.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() { object : MediaPlayerEventAdapter() {
override fun error(mediaPlayer: MediaPlayer) { override fun error(mediaPlayer: MediaPlayer) {
println("VLC thumbnail: playback error for $url")
latch.countDown() latch.countDown()
} }
}, },
@@ -120,8 +126,12 @@ object VideoThumbnailCache {
player.media().play(url) player.media().play(url)
// Wait up to 5 seconds for first frame // Wait up to 8 seconds for first frame (network videos can be slow)
latch.await(5, TimeUnit.SECONDS) latch.await(8, TimeUnit.SECONDS)
if (result == null) {
println("VLC thumbnail: timed out or failed for $url")
}
VlcjPlayerPool.release(player) VlcjPlayerPool.release(player)
return result return result
@@ -28,6 +28,8 @@ import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback 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.RenderCallback
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
/** /**
@@ -39,12 +41,19 @@ import java.util.concurrent.atomic.AtomicBoolean
*/ */
object VlcjPlayerPool { object VlcjPlayerPool {
private val available = AtomicBoolean(false) private val available = AtomicBoolean(false)
private val initAttempted = AtomicBoolean(false)
private val initLatch = CountDownLatch(1)
private var factory: MediaPlayerFactory? = null private var factory: MediaPlayerFactory? = null
// Video player pool // 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 = 3 private const val MAX_POOL_SIZE = 4
// Thumbnail player pool (separate so thumbnails don't compete with playback)
private val allThumbPlayers = mutableListOf<EmbeddedMediaPlayer>()
private val idleThumbPlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
private const val MAX_THUMB_POOL_SIZE = 2
// Audio player pool (shared factory with --no-video) // Audio player pool (shared factory with --no-video)
private var audioFactory: MediaPlayerFactory? = null private var audioFactory: MediaPlayerFactory? = null
@@ -53,10 +62,18 @@ object VlcjPlayerPool {
private const val MAX_AUDIO_POOL_SIZE = 5 private const val MAX_AUDIO_POOL_SIZE = 5
/** /**
* Initialize the pool. Returns false if VLC is not installed. * Initialize the pool. Thread-safe — only runs once.
* Returns false if VLC is not installed.
*/ */
fun init(): Boolean { fun init(): Boolean {
if (available.get()) return true if (available.get()) return true
// Only one thread performs init; others wait
if (!initAttempted.compareAndSet(false, true)) {
initLatch.await(10, TimeUnit.SECONDS)
return available.get()
}
return try { return try {
// Try bundled VLC first, then fall through to system VLC // Try bundled VLC first, then fall through to system VLC
val discovery = val discovery =
@@ -91,6 +108,8 @@ object VlcjPlayerPool {
println("VLC: init failed — ${e.message}") println("VLC: init failed — ${e.message}")
available.set(false) available.set(false)
false false
} finally {
initLatch.countDown()
} }
} }
@@ -128,6 +147,30 @@ object VlcjPlayerPool {
} }
} }
/**
* Acquire a player dedicated to thumbnail extraction.
* Separate pool so thumbnails don't compete with playback.
*/
fun acquireForThumbnail(): EmbeddedMediaPlayer? {
if (!available.get()) return null
val f = factory ?: return null
synchronized(allThumbPlayers) {
idleThumbPlayers.poll()?.let { return it }
if (allThumbPlayers.size >= MAX_THUMB_POOL_SIZE) {
// Fall back to main pool if thumb pool is full
return acquire()
}
return try {
val player = f.mediaPlayers().newEmbeddedMediaPlayer()
allThumbPlayers.add(player)
player
} catch (_: Exception) {
null
}
}
}
/** /**
* Acquire an audio-only player from the pool. * Acquire an audio-only player from the pool.
* Uses a separate factory with --no-video for efficiency. * Uses a separate factory with --no-video for efficiency.
@@ -162,6 +205,13 @@ object VlcjPlayerPool {
fun release(player: EmbeddedMediaPlayer) { fun release(player: EmbeddedMediaPlayer) {
try { try {
player.controls().stop() player.controls().stop()
// Return to correct pool
synchronized(allThumbPlayers) {
if (player in allThumbPlayers) {
idleThumbPlayers.offer(player)
return
}
}
idlePlayers.offer(player) idlePlayers.offer(player)
} catch (_: Exception) { } catch (_: Exception) {
// Player may already be disposed // Player may already be disposed
@@ -196,6 +246,18 @@ object VlcjPlayerPool {
} }
allPlayers.clear() allPlayers.clear()
} }
synchronized(allThumbPlayers) {
idleThumbPlayers.clear()
for (player in allThumbPlayers) {
try {
player.controls().stop()
player.release()
} catch (_: Exception) {
// Ignore
}
}
allThumbPlayers.clear()
}
synchronized(allAudioPlayers) { synchronized(allAudioPlayers) {
idleAudioPlayers.clear() idleAudioPlayers.clear()
for (player in allAudioPlayers) { for (player in allAudioPlayers) {
@@ -289,7 +289,9 @@ fun BookmarksScreen(
) { ) {
NoteCard( NoteCard(
note = event.toNoteDisplayData(localCache), note = event.toNoteDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
) )
NoteActionsRow( NoteActionsRow(
event = event, event = event,
@@ -55,6 +55,7 @@ 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.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
@@ -77,6 +78,7 @@ 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.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.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -91,6 +93,7 @@ data class LightboxState(
val urls: List<String>, val urls: List<String>,
val index: Int, val index: Int,
val seekPosition: Float = 0f, val seekPosition: Float = 0f,
val fullscreen: Boolean = false,
) )
/** /**
@@ -127,7 +130,9 @@ fun FeedNoteCard(
) { ) {
NoteCard( NoteCard(
note = event.toNoteDisplayData(localCache), note = event.toNoteDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
onImageClick = onImageClick, onImageClick = onImageClick,
onMediaClick = onMediaClick, onMediaClick = onMediaClick,
) )
@@ -446,30 +451,40 @@ fun FeedScreen(
) )
} }
// Subscribe to metadata for note authors (to enable zaps and populate search cache) // Subscribe to metadata for note authors + mentioned users
val authorPubkeys = events.map { it.pubKey }.distinct() val authorPubkeys = events.map { it.pubKey }.distinct()
val mentionedPubkeys =
remember(events) {
val parser = UrlParser()
events
.flatMap { event ->
val urls = parser.parseValidUrls(event.content)
extractMentionedPubkeys(urls.bech32s)
}.distinct()
}
val allPubkeys = remember(authorPubkeys, mentionedPubkeys) { (authorPubkeys + mentionedPubkeys).distinct() }
// Use coordinator for rate-limited metadata loading (preferred) // Use coordinator for rate-limited metadata loading (preferred)
LaunchedEffect(authorPubkeys, subscriptionsCoordinator) { LaunchedEffect(allPubkeys, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && authorPubkeys.isNotEmpty()) { if (subscriptionsCoordinator != null && allPubkeys.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForPubkeys(authorPubkeys) subscriptionsCoordinator.loadMetadataForPubkeys(allPubkeys)
} }
} }
// Fallback subscription if coordinator not available // Fallback subscription if coordinator not available
rememberSubscription(configuredRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) { rememberSubscription(configuredRelays, allPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
// Skip if using coordinator // Skip if using coordinator
if (subscriptionsCoordinator != null) { if (subscriptionsCoordinator != null) {
return@rememberSubscription null return@rememberSubscription null
} }
if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) { if (configuredRelays.isEmpty() || allPubkeys.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
// Only fetch metadata for users we don't have yet // Only fetch metadata for users we don't have yet
val missingPubkeys = val missingPubkeys =
authorPubkeys.filter { pubkey -> allPubkeys.filter { pubkey ->
localCache localCache
.getUserIfExists(pubkey) .getUserIfExists(pubkey)
?.metadataOrNull() ?.metadataOrNull()
@@ -614,7 +629,7 @@ fun FeedScreen(
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread, onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onMediaClick = { urls, index, seekPos -> lightboxState = LightboxState(urls, index, seekPos) }, onMediaClick = { urls, index, seekPos -> lightboxState = LightboxState(urls, index, seekPos, fullscreen = true) },
zapReceipts = zapsByEvent[event.id] ?: emptyList(), zapReceipts = zapsByEvent[event.id] ?: emptyList(),
reactionCount = reactionsByEvent[event.id] ?: 0, reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0, replyCount = repliesByEvent[event.id] ?: 0,
@@ -652,6 +667,7 @@ fun FeedScreen(
urls = state.urls, urls = state.urls,
initialIndex = state.index, initialIndex = state.index,
initialSeekPosition = state.seekPosition, initialSeekPosition = state.seekPosition,
initialFullscreen = state.fullscreen,
onDismiss = { lightboxState = null }, onDismiss = { lightboxState = null },
) )
} }
@@ -51,6 +51,7 @@ 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.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
@@ -69,6 +70,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubsc
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.note.NoteCard import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
@@ -135,12 +137,22 @@ 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()) }
// Load metadata for thread authors 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) {
val pubkeys = mutableListOf<String>() val pubkeys = mutableListOf<String>()
rootNote?.let { pubkeys.add(it.pubKey) } rootNote?.let { pubkeys.add(it.pubKey) }
pubkeys.addAll(replyEvents.map { it.pubKey }) pubkeys.addAll(replyEvents.map { it.pubKey })
// Also load metadata for users mentioned in note content
val parser = UrlParser()
val allEvents = listOfNotNull(rootNote) + replyEvents
val mentionedPubkeys =
allEvents.flatMap { event ->
extractMentionedPubkeys(parser.parseValidUrls(event.content).bech32s)
}
pubkeys.addAll(mentionedPubkeys)
if (pubkeys.isNotEmpty()) { if (pubkeys.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct()) subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct())
} }
@@ -377,7 +389,9 @@ fun ThreadScreen(
) { ) {
NoteCard( NoteCard(
note = rootNote!!.toNoteDisplayData(localCache), note = rootNote!!.toNoteDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
) )
if (account != null) { if (account != null) {
val rootZaps = zapsByEvent[noteId] ?: emptyList() val rootZaps = zapsByEvent[noteId] ?: emptyList()
@@ -436,7 +450,9 @@ fun ThreadScreen(
) { ) {
NoteCard( NoteCard(
note = event.toNoteDisplayData(localCache), note = event.toNoteDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
) )
if (account != null) { if (account != null) {
val eventZaps = zapsByEvent[event.id] ?: emptyList() val eventZaps = zapsByEvent[event.id] ?: emptyList()
@@ -714,7 +714,7 @@ fun UserProfileScreen(
lightboxState = LightboxState(urls, index) lightboxState = LightboxState(urls, index)
}, },
onMediaClick = { urls, index, seekPos -> onMediaClick = { urls, index, seekPos ->
lightboxState = LightboxState(urls, index, seekPos) lightboxState = LightboxState(urls, index, seekPos, fullscreen = true)
}, },
) )
} }
@@ -776,6 +776,7 @@ fun UserProfileScreen(
urls = state.urls, urls = state.urls,
initialIndex = state.index, initialIndex = state.index,
initialSeekPosition = state.seekPosition, initialSeekPosition = state.seekPosition,
initialFullscreen = state.fullscreen,
onDismiss = { lightboxState = null }, onDismiss = { lightboxState = null },
) )
} }
@@ -96,11 +96,19 @@ fun DesktopVideoPlayer(
// Lazy activation — don't touch VLC until user clicks play (or autoPlay) // Lazy activation — don't touch VLC until user clicks play (or autoPlay)
var activated by remember { mutableStateOf(autoPlay) } var activated by remember { mutableStateOf(autoPlay) }
// Load thumbnail when not activated // 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) { LaunchedEffect(url, activated) {
if (!activated && thumbnail == null) { if (!activated && thumbnail == null) {
thumbnail = VideoThumbnailCache.getThumbnail(url) // Retry up to 3 times with increasing delay
for (attempt in 1..3) {
val result = VideoThumbnailCache.getThumbnail(url)
if (result != null) {
thumbnail = result
break
}
if (attempt < 3) delay(2000L * attempt)
}
} }
} }
@@ -91,7 +91,7 @@ val LocalAwtWindow = compositionLocalOf<java.awt.Window?> { null }
val LocalIsImmersiveFullscreen = compositionLocalOf { mutableStateOf(false) } val LocalIsImmersiveFullscreen = compositionLocalOf { mutableStateOf(false) }
enum class ViewMode { DEFAULT, THEATER, FULLSCREEN } enum class ViewMode { DEFAULT, FULLSCREEN }
private sealed class DownloadState { private sealed class DownloadState {
data object Idle : DownloadState() data object Idle : DownloadState()
@@ -115,6 +115,7 @@ fun LightboxOverlay(
urls: List<String>, urls: List<String>,
initialIndex: Int = 0, initialIndex: Int = 0,
initialSeekPosition: Float = 0f, initialSeekPosition: Float = 0f,
initialFullscreen: Boolean = false,
onDismiss: () -> Unit, onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -123,13 +124,10 @@ fun LightboxOverlay(
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
var downloadState by remember { mutableStateOf<DownloadState>(DownloadState.Idle) } var downloadState by remember { mutableStateOf<DownloadState>(DownloadState.Idle) }
var viewMode by remember { mutableStateOf(ViewMode.DEFAULT) } var viewMode by remember { mutableStateOf(if (initialFullscreen) ViewMode.FULLSCREEN else ViewMode.DEFAULT) }
val awtWindow = LocalAwtWindow.current val awtWindow = LocalAwtWindow.current
val isImmersiveFullscreen = LocalIsImmersiveFullscreen.current val isImmersiveFullscreen = LocalIsImmersiveFullscreen.current
// Track what mode we came from before fullscreen, so Esc returns there
var preFullscreenMode by remember { mutableStateOf(ViewMode.DEFAULT) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
} }
@@ -187,20 +185,16 @@ fun LightboxOverlay(
} }
fun toggleFullscreen() { fun toggleFullscreen() {
if (viewMode == ViewMode.FULLSCREEN) { viewMode =
viewMode = preFullscreenMode if (viewMode == ViewMode.FULLSCREEN) ViewMode.DEFAULT else ViewMode.FULLSCREEN
} else {
preFullscreenMode = viewMode
viewMode = ViewMode.FULLSCREEN
}
} }
// Content modifier based on view mode // Content modifier based on view mode
val contentModifier = val contentModifier =
when (viewMode) { if (viewMode == ViewMode.DEFAULT) {
ViewMode.DEFAULT -> Modifier.fillMaxSize().padding(48.dp) Modifier.fillMaxSize().padding(48.dp)
ViewMode.THEATER -> Modifier.fillMaxSize() } else {
ViewMode.FULLSCREEN -> Modifier.fillMaxSize() Modifier.fillMaxSize()
} }
Box( Box(
@@ -214,8 +208,6 @@ fun LightboxOverlay(
when (event.key) { when (event.key) {
Key.Escape -> { Key.Escape -> {
if (viewMode == ViewMode.FULLSCREEN) { if (viewMode == ViewMode.FULLSCREEN) {
viewMode = preFullscreenMode
} else if (viewMode == ViewMode.THEATER) {
viewMode = ViewMode.DEFAULT viewMode = ViewMode.DEFAULT
} else { } else {
onDismiss() onDismiss()
@@ -223,21 +215,6 @@ fun LightboxOverlay(
true true
} }
Key.T -> {
if (viewMode == ViewMode.FULLSCREEN) {
// Don't toggle theater while fullscreen
false
} else {
viewMode =
if (viewMode == ViewMode.THEATER) {
ViewMode.DEFAULT
} else {
ViewMode.THEATER
}
true
}
}
Key.F -> { Key.F -> {
toggleFullscreen() toggleFullscreen()
true true
@@ -285,9 +262,6 @@ fun LightboxOverlay(
initialSeekPosition = if (currentIndex == initialIndex) initialSeekPosition else 0f, initialSeekPosition = if (currentIndex == initialIndex) initialSeekPosition else 0f,
viewMode = viewMode, viewMode = viewMode,
onViewModeChange = { newMode -> onViewModeChange = { newMode ->
if (newMode == ViewMode.FULLSCREEN && viewMode != ViewMode.FULLSCREEN) {
preFullscreenMode = viewMode
}
viewMode = newMode viewMode = newMode
}, },
modifier = modifier =
@@ -38,6 +38,7 @@ object SaveMediaAction {
suspend fun saveMedia( suspend fun saveMedia(
url: String, url: String,
suggestedFilename: String? = null, suggestedFilename: String? = null,
onProgress: ((downloaded: Long, total: Long) -> Unit)? = null,
): File? { ): File? {
val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" } val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
@@ -61,9 +62,17 @@ object SaveMediaAction {
val response = httpClient.newCall(request).execute() val response = httpClient.newCall(request).execute()
response.use { resp -> response.use { resp ->
if (!resp.isSuccessful) return@withContext null if (!resp.isSuccessful) return@withContext null
val total = resp.body.contentLength()
resp.body.byteStream().use { input -> resp.body.byteStream().use { input ->
file.outputStream().use { output -> file.outputStream().use { output ->
input.copyTo(output) val buffer = ByteArray(8192)
var downloaded = 0L
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
downloaded += bytesRead
onProgress?.invoke(downloaded, total)
}
} }
} }
} }
@@ -37,10 +37,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeOff import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.CloseFullscreen
import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.OpenInFull
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -206,33 +204,8 @@ fun VideoControls(
) )
} }
// Two separate toggle buttons (lightbox) or simple fullscreen (inline) // Fullscreen toggle (lightbox) or simple fullscreen (inline)
if (onViewModeChange != null) { if (onViewModeChange != null) {
// Theater toggle — hidden during fullscreen
if (viewMode != ViewMode.FULLSCREEN) {
IconButton(
onClick = {
onViewModeChange(
if (viewMode == ViewMode.THEATER) ViewMode.DEFAULT else ViewMode.THEATER,
)
},
modifier = Modifier.size(32.dp),
) {
Icon(
if (viewMode == ViewMode.THEATER) {
Icons.Default.CloseFullscreen
} else {
Icons.Default.OpenInFull
},
contentDescription =
if (viewMode == ViewMode.THEATER) "Exit theater" else "Theater mode",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
// Fullscreen toggle — always visible
IconButton( IconButton(
onClick = { onClick = {
onViewModeChange( onViewModeChange(
@@ -42,9 +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.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@@ -53,9 +56,15 @@ import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.richtext.Urls 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.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.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
private val AUDIO_EXTENSIONS = setOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a") private val AUDIO_EXTENSIONS = setOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a")
@@ -79,8 +88,10 @@ data class NoteDisplayData(
fun NoteCard( fun NoteCard(
note: NoteDisplayData, note: NoteDisplayData,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
localCache: DesktopLocalCache? = null,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onAuthorClick: ((String) -> Unit)? = null, onAuthorClick: ((String) -> Unit)? = null,
onMentionClick: ((String) -> Unit)? = null,
onImageClick: ((List<String>, Int) -> Unit)? = null, onImageClick: ((List<String>, Int) -> Unit)? = null,
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null, onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
) { ) {
@@ -193,6 +204,8 @@ fun NoteCard(
RichTextContent( RichTextContent(
content = strippedContent, content = strippedContent,
urls = strippedUrls, urls = strippedUrls,
localCache = localCache,
onMentionClick = onMentionClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
@@ -281,48 +294,152 @@ fun NoteCard(
} }
/** /**
* Renders text content with highlighted URLs. * Resolved bech32 mention with display text and optional pubkey for click navigation.
* Uses RichTextParser from commons to detect and highlight links. */
private data class ResolvedMention(
val displayText: String,
val pubKeyHex: String? = null,
)
/**
* Resolves a nostr: bech32 reference to a display string and optional pubkey.
* For npub/nprofile @displayName + pubkey hex for navigation.
* For note/nevent truncated note ID.
*/
private fun resolveBech32(
bech32: String,
localCache: DesktopLocalCache?,
): ResolvedMention {
val parsed = Nip19Parser.uriToRoute(bech32) ?: return ResolvedMention(bech32)
return when (val entity = parsed.entity) {
is NPub -> {
val user = localCache?.getUserIfExists(entity.hex)
ResolvedMention(
displayText = "@${user?.toBestDisplayName() ?: entity.hex.take(8) + "..."}",
pubKeyHex = entity.hex,
)
}
is NProfile -> {
val user = localCache?.getUserIfExists(entity.hex)
ResolvedMention(
displayText = "@${user?.toBestDisplayName() ?: entity.hex.take(8) + "..."}",
pubKeyHex = entity.hex,
)
}
is NNote -> {
ResolvedMention("note:${entity.hex.take(8)}...")
}
is NEvent -> {
ResolvedMention("note:${entity.hex.take(8)}...")
}
else -> {
ResolvedMention(bech32.take(24) + "...")
}
}
}
/**
* Extracts pubkey hex strings from all npub/nprofile bech32 references in a set.
* Used to trigger metadata loading for mentioned users.
*/
fun extractMentionedPubkeys(bech32s: Set<String>): List<String> =
bech32s.mapNotNull { bech32 ->
val parsed = Nip19Parser.uriToRoute(bech32) ?: return@mapNotNull null
when (val entity = parsed.entity) {
is NPub -> entity.hex
is NProfile -> entity.hex
else -> null
}
}
/**
* Renders text content with highlighted URLs and clickable nostr: bech32 mentions.
* URLs are underlined in primary color; bech32 mentions show as @displayName in primary color
* and navigate to profile on click.
*/ */
@Composable @Composable
fun RichTextContent( fun RichTextContent(
content: String, content: String,
urls: Urls, urls: Urls,
localCache: DesktopLocalCache? = null,
onMentionClick: ((String) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (urls.withScheme.isEmpty()) { val defaultColor = MaterialTheme.colorScheme.onSurface
val primaryColor = MaterialTheme.colorScheme.primary
if (urls.withScheme.isEmpty() && urls.bech32s.isEmpty()) {
Text( Text(
text = content, text = content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, color = defaultColor,
modifier = modifier, modifier = modifier,
) )
} else { } else {
data class Segment(
val start: Int,
val raw: String,
val isUrl: Boolean,
)
val segments = mutableListOf<Segment>()
for (url in urls.withScheme) {
val idx = content.indexOf(url)
if (idx != -1) segments.add(Segment(idx, url, true))
}
for (bech32 in urls.bech32s) {
val idx = content.indexOf(bech32)
if (idx != -1) segments.add(Segment(idx, bech32, false))
}
segments.sortBy { it.start }
val annotatedText = val annotatedText =
buildAnnotatedString { buildAnnotatedString {
var lastIndex = 0 var lastIndex = 0
val sortedUrls = urls.withScheme.sortedBy { content.indexOf(it) }
for (url in sortedUrls) { for (segment in segments) {
val startIndex = content.indexOf(url, lastIndex) if (segment.start < lastIndex) continue
if (startIndex == -1) continue
// Add text before URL // Add text before segment
if (startIndex > lastIndex) { if (segment.start > lastIndex) {
append(content.substring(lastIndex, startIndex)) append(content.substring(lastIndex, segment.start))
} }
// Add URL with styling if (segment.isUrl) {
withStyle( withStyle(
SpanStyle( SpanStyle(
color = MaterialTheme.colorScheme.primary, color = primaryColor,
textDecoration = TextDecoration.Underline, textDecoration = TextDecoration.Underline,
), ),
) { ) {
append(url) append(segment.raw)
}
} else {
val resolved = resolveBech32(segment.raw, localCache)
if (resolved.pubKeyHex != null && onMentionClick != null) {
val pubKey = resolved.pubKeyHex
withLink(
LinkAnnotation.Clickable(
tag = "mention",
styles = TextLinkStyles(SpanStyle(color = primaryColor)),
) {
onMentionClick(pubKey)
},
) {
append(resolved.displayText)
}
} else {
withStyle(SpanStyle(color = primaryColor)) {
append(resolved.displayText)
}
}
} }
lastIndex = startIndex + url.length lastIndex = segment.start + segment.raw.length
} }
// Add remaining text // Add remaining text
@@ -334,7 +451,7 @@ fun RichTextContent(
Text( Text(
text = annotatedText, text = annotatedText,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, color = defaultColor,
modifier = modifier, modifier = modifier,
) )
} }
@@ -160,8 +160,10 @@ fun SearchResultsList(
items(displayNotes, key = { "note-${it.id}" }) { event -> items(displayNotes, key = { "note-${it.id}" }) { event ->
NoteCard( NoteCard(
note = event.toNoteDisplayData(localCache), note = event.toNoteDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) }, onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
) )
} }
if (textNotes.size > 5) { if (textNotes.size > 5) {
@@ -202,8 +204,10 @@ fun SearchResultsList(
items(articles.take(5), key = { "article-${it.id}" }) { event -> items(articles.take(5), key = { "article-${it.id}" }) { event ->
NoteCard( NoteCard(
note = event.toNoteDisplayData(localCache), note = event.toNoteDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) }, onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
) )
} }
if (articles.size > 5) { if (articles.size > 5) {
@@ -242,8 +246,10 @@ fun SearchResultsList(
items(otherNotes.take(5), key = { "other-${it.id}" }) { event -> items(otherNotes.take(5), key = { "other-${it.id}" }) { event ->
NoteCard( NoteCard(
note = event.toNoteDisplayData(localCache), note = event.toNoteDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) }, onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile, onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
) )
} }
} }