From 6e1315a4faca399791b72f295196d99d1f9d7ef8 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 18 Mar 2026 09:24:00 +0200 Subject: [PATCH] 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) --- .../service/media/VideoThumbnailCache.kt | 34 ++-- .../desktop/service/media/VlcjPlayerPool.kt | 68 +++++++- .../amethyst/desktop/ui/BookmarksScreen.kt | 2 + .../amethyst/desktop/ui/FeedScreen.kt | 32 +++- .../amethyst/desktop/ui/ThreadScreen.kt | 18 +- .../amethyst/desktop/ui/UserProfileScreen.kt | 3 +- .../desktop/ui/media/DesktopVideoPlayer.kt | 12 +- .../desktop/ui/media/LightboxOverlay.kt | 44 +---- .../desktop/ui/media/SaveMediaAction.kt | 11 +- .../desktop/ui/media/VideoControls.kt | 29 +--- .../amethyst/desktop/ui/note/NoteCard.kt | 159 +++++++++++++++--- .../desktop/ui/search/SearchResultsList.kt | 6 + 12 files changed, 306 insertions(+), 112 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt index aabb4c5fd..6ecaabe15 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt @@ -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) {} } @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt index 0541476ba..37225791c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt @@ -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() private val idlePlayers = ConcurrentLinkedQueue() - 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() + private val idleThumbPlayers = ConcurrentLinkedQueue() + 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) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index c4f1570c6..f08682e56 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -289,7 +289,9 @@ fun BookmarksScreen( ) { NoteCard( note = event.toNoteDisplayData(localCache), + localCache = localCache, onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, ) NoteActionsRow( event = event, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 2d05fc0d8..378fcbf82 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -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, 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 }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index ca79ca553..d9738a3ad 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -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(null) } var bookmarkedEventIds by remember { mutableStateOf>(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() 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() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index fbe3e0572..20cdbae08 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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 }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt index 141081643..585015025 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -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) + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt index 5593c93ab..e9e731712 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt @@ -91,7 +91,7 @@ val LocalAwtWindow = compositionLocalOf { 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, 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.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 = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt index f762d3096..e29518db6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt @@ -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) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt index a83041d61..0b82e69f1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/VideoControls.kt @@ -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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 5701dcba1..627965002 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -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, Int) -> Unit)? = null, onMediaClick: ((List, 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): List = + 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() + 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, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt index caf9539f7..2a50d2ce8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -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, ) } }