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? {
if (!VlcjPlayerPool.init()) return null
val player = VlcjPlayerPool.acquire() ?: return null
if (!VlcjPlayerPool.init()) {
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
val latch = CountDownLatch(1)
var aspectRatio = 16f / 9f
val bufferFormatCallback =
object : BufferFormatCallback {
override fun getBufferFormat(
sourceWidth: Int,
sourceHeight: Int,
): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat {
if (sourceHeight > 0) {
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat()
}
return RV32BufferFormat(sourceWidth, sourceHeight)
}
): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat = RV32BufferFormat(sourceWidth, sourceHeight)
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
}
@@ -84,8 +85,10 @@ object VideoThumbnailCache {
RenderCallback { _, nativeBuffers, bufferFormat ->
if (result != null) return@RenderCallback
try {
if (nativeBuffers.isEmpty()) return@RenderCallback
val w = bufferFormat.width
val h = bufferFormat.height
if (w <= 0 || h <= 0) return@RenderCallback
val bmp = Bitmap()
bmp.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
val bytes = ByteArray(w * h * 4)
@@ -95,13 +98,15 @@ object VideoThumbnailCache {
bmp.installPixels(bytes)
result = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
latch.countDown()
} catch (_: Exception) {
} catch (e: Exception) {
println("VLC thumbnail: render error for $url${e.message}")
latch.countDown()
}
}
val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
if (surface == null) {
println("VLC thumbnail: surface creation failed for $url")
VlcjPlayerPool.release(player)
return null
}
@@ -113,6 +118,7 @@ object VideoThumbnailCache {
player.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() {
override fun error(mediaPlayer: MediaPlayer) {
println("VLC thumbnail: playback error for $url")
latch.countDown()
}
},
@@ -120,8 +126,12 @@ object VideoThumbnailCache {
player.media().play(url)
// Wait up to 5 seconds for first frame
latch.await(5, TimeUnit.SECONDS)
// Wait up to 8 seconds for first frame (network videos can be slow)
latch.await(8, TimeUnit.SECONDS)
if (result == null) {
println("VLC thumbnail: timed out or failed for $url")
}
VlcjPlayerPool.release(player)
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.RenderCallback
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
@@ -39,12 +41,19 @@ import java.util.concurrent.atomic.AtomicBoolean
*/
object VlcjPlayerPool {
private val available = AtomicBoolean(false)
private val initAttempted = AtomicBoolean(false)
private val initLatch = CountDownLatch(1)
private var factory: MediaPlayerFactory? = null
// Video player pool
// Video player pool (for actual playback)
private val allPlayers = mutableListOf<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)
private var audioFactory: MediaPlayerFactory? = null
@@ -53,10 +62,18 @@ object VlcjPlayerPool {
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 {
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 {
// Try bundled VLC first, then fall through to system VLC
val discovery =
@@ -91,6 +108,8 @@ object VlcjPlayerPool {
println("VLC: init failed — ${e.message}")
available.set(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.
* Uses a separate factory with --no-video for efficiency.
@@ -162,6 +205,13 @@ object VlcjPlayerPool {
fun release(player: EmbeddedMediaPlayer) {
try {
player.controls().stop()
// Return to correct pool
synchronized(allThumbPlayers) {
if (player in allThumbPlayers) {
idleThumbPlayers.offer(player)
return
}
}
idlePlayers.offer(player)
} catch (_: Exception) {
// Player may already be disposed
@@ -196,6 +246,18 @@ object VlcjPlayerPool {
}
allPlayers.clear()
}
synchronized(allThumbPlayers) {
idleThumbPlayers.clear()
for (player in allThumbPlayers) {
try {
player.controls().stop()
player.release()
} catch (_: Exception) {
// Ignore
}
}
allThumbPlayers.clear()
}
synchronized(allAudioPlayers) {
idleAudioPlayers.clear()
for (player in allAudioPlayers) {
@@ -289,7 +289,9 @@ fun BookmarksScreen(
) {
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
NoteActionsRow(
event = event,
@@ -55,6 +55,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.ui.components.EmptyState
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.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -91,6 +93,7 @@ data class LightboxState(
val urls: List<String>,
val index: Int,
val seekPosition: Float = 0f,
val fullscreen: Boolean = false,
)
/**
@@ -127,7 +130,9 @@ fun FeedNoteCard(
) {
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
onImageClick = onImageClick,
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 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)
LaunchedEffect(authorPubkeys, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && authorPubkeys.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForPubkeys(authorPubkeys)
LaunchedEffect(allPubkeys, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && allPubkeys.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForPubkeys(allPubkeys)
}
}
// Fallback subscription if coordinator not available
rememberSubscription(configuredRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
rememberSubscription(configuredRelays, allPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
// Skip if using coordinator
if (subscriptionsCoordinator != null) {
return@rememberSubscription null
}
if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) {
if (configuredRelays.isEmpty() || allPubkeys.isEmpty()) {
return@rememberSubscription null
}
// Only fetch metadata for users we don't have yet
val missingPubkeys =
authorPubkeys.filter { pubkey ->
allPubkeys.filter { pubkey ->
localCache
.getUserIfExists(pubkey)
?.metadataOrNull()
@@ -614,7 +629,7 @@ fun FeedScreen(
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
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(),
reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0,
@@ -652,6 +667,7 @@ fun FeedScreen(
urls = state.urls,
initialIndex = state.index,
initialSeekPosition = state.seekPosition,
initialFullscreen = state.fullscreen,
onDismiss = { lightboxState = null },
)
}
@@ -51,6 +51,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.ui.components.EmptyState
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.rememberSubscription
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.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
@@ -135,12 +137,22 @@ fun ThreadScreen(
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
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) {
if (subscriptionsCoordinator != null) {
val pubkeys = mutableListOf<String>()
rootNote?.let { pubkeys.add(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()) {
subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct())
}
@@ -377,7 +389,9 @@ fun ThreadScreen(
) {
NoteCard(
note = rootNote!!.toNoteDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
if (account != null) {
val rootZaps = zapsByEvent[noteId] ?: emptyList()
@@ -436,7 +450,9 @@ fun ThreadScreen(
) {
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
if (account != null) {
val eventZaps = zapsByEvent[event.id] ?: emptyList()
@@ -714,7 +714,7 @@ fun UserProfileScreen(
lightboxState = LightboxState(urls, index)
},
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,
initialIndex = state.index,
initialSeekPosition = state.seekPosition,
initialFullscreen = state.fullscreen,
onDismiss = { lightboxState = null },
)
}
@@ -96,11 +96,19 @@ fun DesktopVideoPlayer(
// Lazy activation — don't touch VLC until user clicks play (or 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)) }
LaunchedEffect(url, activated) {
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) }
enum class ViewMode { DEFAULT, THEATER, FULLSCREEN }
enum class ViewMode { DEFAULT, FULLSCREEN }
private sealed class DownloadState {
data object Idle : DownloadState()
@@ -115,6 +115,7 @@ fun LightboxOverlay(
urls: List<String>,
initialIndex: Int = 0,
initialSeekPosition: Float = 0f,
initialFullscreen: Boolean = false,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -123,13 +124,10 @@ fun LightboxOverlay(
val focusRequester = remember { FocusRequester() }
var menuExpanded by remember { mutableStateOf(false) }
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 isImmersiveFullscreen = LocalIsImmersiveFullscreen.current
// Track what mode we came from before fullscreen, so Esc returns there
var preFullscreenMode by remember { mutableStateOf(ViewMode.DEFAULT) }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
@@ -187,20 +185,16 @@ fun LightboxOverlay(
}
fun toggleFullscreen() {
if (viewMode == ViewMode.FULLSCREEN) {
viewMode = preFullscreenMode
} else {
preFullscreenMode = viewMode
viewMode = ViewMode.FULLSCREEN
}
viewMode =
if (viewMode == ViewMode.FULLSCREEN) ViewMode.DEFAULT else ViewMode.FULLSCREEN
}
// Content modifier based on view mode
val contentModifier =
when (viewMode) {
ViewMode.DEFAULT -> Modifier.fillMaxSize().padding(48.dp)
ViewMode.THEATER -> Modifier.fillMaxSize()
ViewMode.FULLSCREEN -> Modifier.fillMaxSize()
if (viewMode == ViewMode.DEFAULT) {
Modifier.fillMaxSize().padding(48.dp)
} else {
Modifier.fillMaxSize()
}
Box(
@@ -214,8 +208,6 @@ fun LightboxOverlay(
when (event.key) {
Key.Escape -> {
if (viewMode == ViewMode.FULLSCREEN) {
viewMode = preFullscreenMode
} else if (viewMode == ViewMode.THEATER) {
viewMode = ViewMode.DEFAULT
} else {
onDismiss()
@@ -223,21 +215,6 @@ fun LightboxOverlay(
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 -> {
toggleFullscreen()
true
@@ -285,9 +262,6 @@ fun LightboxOverlay(
initialSeekPosition = if (currentIndex == initialIndex) initialSeekPosition else 0f,
viewMode = viewMode,
onViewModeChange = { newMode ->
if (newMode == ViewMode.FULLSCREEN && viewMode != ViewMode.FULLSCREEN) {
preFullscreenMode = viewMode
}
viewMode = newMode
},
modifier =
@@ -38,6 +38,7 @@ object SaveMediaAction {
suspend fun saveMedia(
url: String,
suggestedFilename: String? = null,
onProgress: ((downloaded: Long, total: Long) -> Unit)? = null,
): File? {
val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
@@ -61,9 +62,17 @@ object SaveMediaAction {
val response = httpClient.newCall(request).execute()
response.use { resp ->
if (!resp.isSuccessful) return@withContext null
val total = resp.body.contentLength()
resp.body.byteStream().use { input ->
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.automirrored.filled.VolumeOff
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.FullscreenExit
import androidx.compose.material.icons.filled.OpenInFull
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
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) {
// 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(
onClick = {
onViewModeChange(
@@ -42,9 +42,12 @@ 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.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
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.ui.components.UserAvatar
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.DesktopVideoPlayer
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")
@@ -79,8 +88,10 @@ data class NoteDisplayData(
fun NoteCard(
note: NoteDisplayData,
modifier: Modifier = Modifier,
localCache: DesktopLocalCache? = null,
onClick: (() -> Unit)? = null,
onAuthorClick: ((String) -> Unit)? = null,
onMentionClick: ((String) -> Unit)? = null,
onImageClick: ((List<String>, Int) -> Unit)? = null,
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
) {
@@ -193,6 +204,8 @@ fun NoteCard(
RichTextContent(
content = strippedContent,
urls = strippedUrls,
localCache = localCache,
onMentionClick = onMentionClick,
modifier = Modifier.fillMaxWidth(),
)
}
@@ -281,48 +294,152 @@ fun NoteCard(
}
/**
* Renders text content with highlighted URLs.
* Uses RichTextParser from commons to detect and highlight links.
* Resolved bech32 mention with display text and optional pubkey for click navigation.
*/
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
fun RichTextContent(
content: String,
urls: Urls,
localCache: DesktopLocalCache? = null,
onMentionClick: ((String) -> Unit)? = null,
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 = content,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
color = defaultColor,
modifier = modifier,
)
} 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 =
buildAnnotatedString {
var lastIndex = 0
val sortedUrls = urls.withScheme.sortedBy { content.indexOf(it) }
for (url in sortedUrls) {
val startIndex = content.indexOf(url, lastIndex)
if (startIndex == -1) continue
for (segment in segments) {
if (segment.start < lastIndex) continue
// Add text before URL
if (startIndex > lastIndex) {
append(content.substring(lastIndex, startIndex))
// Add text before segment
if (segment.start > lastIndex) {
append(content.substring(lastIndex, segment.start))
}
// Add URL with styling
withStyle(
SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
) {
append(url)
if (segment.isUrl) {
withStyle(
SpanStyle(
color = primaryColor,
textDecoration = TextDecoration.Underline,
),
) {
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
@@ -334,7 +451,7 @@ fun RichTextContent(
Text(
text = annotatedText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
color = defaultColor,
modifier = modifier,
)
}
@@ -160,8 +160,10 @@ fun SearchResultsList(
items(displayNotes, key = { "note-${it.id}" }) { event ->
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
}
if (textNotes.size > 5) {
@@ -202,8 +204,10 @@ fun SearchResultsList(
items(articles.take(5), key = { "article-${it.id}" }) { event ->
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
}
if (articles.size > 5) {
@@ -242,8 +246,10 @@ fun SearchResultsList(
items(otherNotes.take(5), key = { "other-${it.id}" }) { event ->
NoteCard(
note = event.toNoteDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
}
}