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