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:
+22
-12
@@ -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
|
||||||
|
|||||||
+65
-3
@@ -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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -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()
|
||||||
|
|||||||
+2
-1
@@ -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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-35
@@ -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 =
|
||||||
|
|||||||
+10
-1
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-28
@@ -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(
|
||||||
|
|||||||
+138
-21
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user