feat(media): lightbox overlay with zoom, gallery navigation, and save

Phase 5: Full-screen lightbox overlay with mouse wheel zoom + pan,
left/right gallery carousel, keyboard shortcuts (Esc/arrows/Ctrl+S),
save to disk via FileDialog. Images in NoteCard are clickable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-16 10:56:26 +02:00
parent 405601463e
commit 1a2dc9efac
5 changed files with 399 additions and 2 deletions
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscriptio
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
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.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -100,6 +101,7 @@ fun FeedNoteCard(
onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
onImageClick: ((List<String>, Int) -> Unit)? = null,
zapReceipts: List<ZapReceipt> = emptyList(),
reactionCount: Int = 0,
replyCount: Int = 0,
@@ -119,6 +121,7 @@ fun FeedNoteCard(
NoteCard(
note = event.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile,
onImageClick = onImageClick,
)
// Action buttons (only if logged in)
@@ -180,6 +183,7 @@ fun FeedScreen(
}
val events by eventState.items.collectAsState()
var replyToEvent by remember { mutableStateOf<Event?>(null) }
var lightboxState by remember { mutableStateOf<Pair<List<String>, Int>?>(null) }
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
@@ -601,6 +605,7 @@ fun FeedScreen(
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index -> lightboxState = urls to index },
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0,
@@ -631,5 +636,14 @@ fun FeedScreen(
replyTo = replyToEvent,
)
}
// Lightbox overlay
lightboxState?.let { (urls, index) ->
LightboxOverlay(
urls = urls,
initialIndex = index,
onDismiss = { lightboxState = null },
)
}
}
}
@@ -0,0 +1,205 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.media
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Composable
fun LightboxOverlay(
urls: List<String>,
initialIndex: Int = 0,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var currentIndex by remember { mutableIntStateOf(initialIndex.coerceIn(0, urls.lastIndex)) }
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Box(
modifier =
modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.9f))
.focusRequester(focusRequester)
.onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
when (event.key) {
Key.Escape -> {
onDismiss()
true
}
Key.DirectionLeft -> {
if (currentIndex > 0) currentIndex--
true
}
Key.DirectionRight -> {
if (currentIndex < urls.lastIndex) currentIndex++
true
}
Key.S -> {
if (event.isCtrlPressed) {
scope.launch {
SaveMediaAction.saveMedia(urls[currentIndex])
}
true
} else {
false
}
}
else -> {
false
}
}
}.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
// Click on backdrop doesn't close — use X button or Esc
},
) {
// Main image
ZoomableImage(
url = urls[currentIndex],
modifier = Modifier.fillMaxSize().padding(48.dp),
)
// Top bar
Row(
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.background(Color.Black.copy(alpha = 0.5f))
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (urls.size > 1) {
Text(
"${currentIndex + 1} / ${urls.size}",
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
)
}
Row {
IconButton(
onClick = {
scope.launch {
SaveMediaAction.saveMedia(urls[currentIndex])
}
},
) {
Icon(
Icons.Default.Save,
contentDescription = "Save",
tint = Color.White,
)
}
IconButton(onClick = onDismiss) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
tint = Color.White,
)
}
}
}
// Navigation arrows
if (urls.size > 1) {
if (currentIndex > 0) {
IconButton(
onClick = { currentIndex-- },
modifier = Modifier.align(Alignment.CenterStart).padding(16.dp),
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Previous",
tint = Color.White,
modifier = Modifier.size(48.dp),
)
}
}
if (currentIndex < urls.lastIndex) {
IconButton(
onClick = { currentIndex++ },
modifier = Modifier.align(Alignment.CenterEnd).padding(16.dp),
) {
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Next",
tint = Color.White,
modifier = Modifier.size(48.dp),
)
}
}
}
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.media
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
object SaveMediaAction {
private val httpClient = OkHttpClient()
/**
* Opens a save dialog and downloads the media URL to the chosen file.
* Returns the saved file path or null if cancelled/failed.
*/
suspend fun saveMedia(
url: String,
suggestedFilename: String? = null,
): File? =
withContext(Dispatchers.IO) {
val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
val dialog =
FileDialog(null as Frame?, "Save Media", FileDialog.SAVE).apply {
file = filename
}
dialog.isVisible = true
val dir = dialog.directory ?: return@withContext null
val file = File(dir, dialog.file ?: return@withContext null)
try {
val request = Request.Builder().url(url).build()
val response = httpClient.newCall(request).execute()
response.use { resp ->
if (!resp.isSuccessful) return@withContext null
resp.body.byteStream().use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
}
}
file
} catch (_: Exception) {
null
}
}
}
@@ -0,0 +1,100 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.media
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ZoomableImage(
url: String,
modifier: Modifier = Modifier,
onDoubleClick: (() -> Unit)? = null,
) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Box(
modifier =
modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Scroll) {
val scrollDelta =
event.changes
.firstOrNull()
?.scrollDelta
?.y ?: 0f
val zoomFactor = if (scrollDelta > 0) 0.9f else 1.1f
scale = (scale * zoomFactor).coerceIn(0.5f, 10f)
event.changes.forEach { it.consume() }
}
}
}
}.pointerInput(Unit) {
detectDragGestures { _, dragAmount ->
if (scale > 1f) {
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
},
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = url,
contentDescription = null,
modifier =
Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY,
),
contentScale = ContentScale.Fit,
)
}
}
fun resetZoom(onReset: () -> Unit) {
onReset()
}
@@ -78,6 +78,7 @@ fun NoteCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onAuthorClick: ((String) -> Unit)? = null,
onImageClick: ((List<String>, Int) -> Unit)? = null,
) {
val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) }
val imageUrls =
@@ -173,7 +174,7 @@ fun NoteCard(
if (strippedContent.isNotBlank()) {
Spacer(Modifier.height(8.dp))
}
for (url in imageUrls) {
for ((index, url) in imageUrls.withIndex()) {
AsyncImage(
model = url,
contentDescription = null,
@@ -181,7 +182,14 @@ fun NoteCard(
Modifier
.fillMaxWidth()
.heightIn(max = 400.dp)
.clip(RoundedCornerShape(8.dp)),
.clip(RoundedCornerShape(8.dp))
.then(
if (onImageClick != null) {
Modifier.clickable { onImageClick(imageUrls, index) }
} else {
Modifier
},
),
contentScale = ContentScale.FillWidth,
)
if (url != imageUrls.last()) {