feat(media): horizontally scrolling image galleries (port wisp #527)

- MediaCarousel: horizontal swipe gallery for multi-image posts with
  page indicators
- FullScreenMediaPager: swipe between images in full screen
- ZoomableAsyncImage: pinch-zoom, pan, double-tap and swipe-down dismiss
- Media layout setting (gallery/stacked) in Interface preferences
This commit is contained in:
The Daniel
2026-06-12 13:17:11 -04:00
parent f3f8c0b199
commit e45d131ae2
9 changed files with 890 additions and 55 deletions
@@ -49,7 +49,8 @@ class MainActivity : FragmentActivity() {
var mediaSettings by remember {
mutableStateOf(MediaSettings(
autoLoadMedia = interfacePrefs.isAutoLoadMedia(),
videoAutoPlay = interfacePrefs.isVideoAutoPlay()
videoAutoPlay = interfacePrefs.isVideoAutoPlay(),
mediaLayoutStyle = interfacePrefs.getMediaLayoutStyle()
))
}
@@ -93,7 +94,8 @@ class MainActivity : FragmentActivity() {
themeName = interfacePrefs.getTheme()
mediaSettings = MediaSettings(
autoLoadMedia = interfacePrefs.isAutoLoadMedia(),
videoAutoPlay = interfacePrefs.isVideoAutoPlay()
videoAutoPlay = interfacePrefs.isVideoAutoPlay(),
mediaLayoutStyle = interfacePrefs.getMediaLayoutStyle()
)
}
)
@@ -3,6 +3,16 @@ package com.darkwisp.app.repo
import android.content.Context
class InterfacePreferences(context: Context) {
enum class MediaLayoutStyle(val key: String) {
GALLERY("gallery"),
STACK("stack");
companion object {
fun fromKey(key: String?): MediaLayoutStyle =
values().firstOrNull { it.key == key } ?: GALLERY
}
}
companion object {
val postUndoTimerOptions = listOf(5, 10, 15, 20, 30)
}
@@ -30,6 +40,11 @@ class InterfacePreferences(context: Context) {
fun isVideoAutoPlay(): Boolean = prefs.getBoolean("video_auto_play", true)
fun setVideoAutoPlay(enabled: Boolean) = prefs.edit().putBoolean("video_auto_play", enabled).apply()
fun getMediaLayoutStyle(): MediaLayoutStyle =
MediaLayoutStyle.fromKey(prefs.getString("media_layout_style", null))
fun setMediaLayoutStyle(style: MediaLayoutStyle) =
prefs.edit().putString("media_layout_style", style.key).apply()
fun getLanguage(): String = prefs.getString("language", "system") ?: "system"
fun setLanguage(language: String) = prefs.edit().putString("language", language).apply()
@@ -65,6 +80,7 @@ class InterfacePreferences(context: Context) {
.remove("post_undo_timer_enabled")
.remove("post_undo_timer_seconds")
.remove("post_undo_timer_for_replies")
.remove("media_layout_style")
.apply()
}
}
@@ -1,10 +1,6 @@
package com.darkwisp.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -21,16 +17,12 @@ import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@@ -38,10 +30,10 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil3.compose.AsyncImage
import com.darkwisp.app.R
import com.darkwisp.app.util.MediaDownloader
import kotlinx.coroutines.launch
import kotlin.math.max
@Composable
fun FullScreenImageViewer(
@@ -52,42 +44,26 @@ fun FullScreenImageViewer(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val transformableState = rememberTransformableState { zoomChange, panChange, _ ->
scale = (scale * zoomChange).coerceIn(0.5f, 5f)
offset = if (scale > 1f) offset + panChange else Offset.Zero
}
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
var dismissDragY by remember { mutableFloatStateOf(0f) }
// Background fades out as the user pulls the image downward, matching
// iOS `max(0.3, 1.0 - dismissY / 250.0)`. Stays fully opaque when not
// dragging.
val backgroundAlpha = max(0.3f, 1f - dismissDragY / 250f)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { if (scale <= 1f) onDismiss() }
),
.background(Color.Black.copy(alpha = backgroundAlpha)),
contentAlignment = Alignment.Center
) {
AsyncImage(
ZoomableAsyncImage(
model = imageUrl,
contentDescription = "Full screen image",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y
)
.transformable(state = transformableState)
onSwipeDownDismiss = onDismiss,
onDismissDrag = { dismissDragY = it }
)
Row(
@@ -0,0 +1,327 @@
package com.darkwisp.app.ui.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.animation.core.Animatable
import com.darkwisp.app.R
import com.darkwisp.app.util.MediaDownloader
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.max
/**
* Full-screen swipeable viewer for any mixed run of images and videos.
* Image pages use [ZoomableAsyncImage] for pinch/pan/double-tap and
* swipe-down-to-dismiss. Video pages show a poster with a centered play
* overlay; tapping the play button hands off to [FullScreenVideoState] —
* the existing single-video full-screen player — so the pager itself
* doesn't have to manage ExoPlayer lifecycles across page changes.
*
* Swipe-down dismissal on a video page is handled by the page itself
* (since [ZoomableAsyncImage] only lives on image pages) using the same
* direction-locked drag detector pattern: horizontal drags fall through
* to the HorizontalPager, vertical drags drive the dismiss.
*/
@Composable
fun FullScreenMediaPager(
items: List<MediaPagerItem>,
initialPage: Int,
onDismiss: () -> Unit
) {
if (items.isEmpty()) return
val startPage = initialPage.coerceIn(0, items.size - 1)
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = startPage, pageCount = { items.size })
var dismissDragY by remember { mutableFloatStateOf(0f) }
// Track each page's contribution so the bg fade only follows the
// page the user is actually pulling, not a stale value from a page
// they swiped past.
val backgroundAlpha = max(0.3f, 1f - dismissDragY / 250f)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = backgroundAlpha))
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
val item = items[page]
when (item) {
is MediaPagerItem.Image -> ZoomableAsyncImage(
model = item.url,
contentDescription = "Image ${page + 1} of ${items.size}",
onSwipeDownDismiss = onDismiss,
onDismissDrag = { y ->
if (page == pagerState.currentPage) dismissDragY = y
}
)
is MediaPagerItem.Video -> VideoPagerPage(
url = item.url,
posterModel = item.posterModel,
onPlay = { FullScreenVideoState.enter(item.url, 0L) },
onSwipeDownDismiss = onDismiss,
onDismissDrag = { y ->
if (page == pagerState.currentPage) dismissDragY = y
}
)
}
}
if (items.size > 1) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color.Black.copy(alpha = 0.5f),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 16.dp)
) {
Text(
text = "${pagerState.currentPage + 1} / ${items.size}",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp)
) {
repeat(items.size) { index ->
Box(
modifier = Modifier
.padding(horizontal = 3.dp)
.size(8.dp)
.clip(CircleShape)
.background(
if (index == pagerState.currentPage) Color.White
else Color.White.copy(alpha = 0.4f)
)
)
}
}
}
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
) {
val buttonColors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Black.copy(alpha = 0.5f),
contentColor = Color.White
)
val currentUrl = items.getOrNull(pagerState.currentPage)?.url
IconButton(
onClick = {
currentUrl?.let { url ->
scope.launch { MediaDownloader.downloadMedia(context, url) }
}
},
colors = buttonColors,
modifier = Modifier.size(40.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_download),
contentDescription = "Download"
)
}
Spacer(Modifier.width(8.dp))
IconButton(
onClick = {
currentUrl?.let { clipboardManager.setText(AnnotatedString(it)) }
},
colors = buttonColors,
modifier = Modifier.size(40.dp)
) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy URL")
}
Spacer(Modifier.width(8.dp))
IconButton(
onClick = onDismiss,
colors = buttonColors,
modifier = Modifier.size(40.dp)
) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
}
}
}
/** Items the pager knows how to render. */
sealed interface MediaPagerItem {
val url: String
data class Image(override val url: String) : MediaPagerItem
/** [posterModel] is a Coil-compatible model used as a still — typically
* the video URL itself (Coil 3's video frame decoder pulls frame 0) or
* a thumbhash painter. */
data class Video(override val url: String, val posterModel: Any? = null) : MediaPagerItem
}
@Composable
private fun VideoPagerPage(
url: String,
posterModel: Any?,
onPlay: () -> Unit,
onSwipeDownDismiss: () -> Unit,
onDismissDrag: (Float) -> Unit
) {
val scope = rememberCoroutineScope()
val touchSlop = LocalViewConfiguration.current.touchSlop
val dragYAnim = remember { Animatable(0f) }
var dragY by remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
snapshotFlow { dragYAnim.value }
.distinctUntilChanged()
.collect {
dragY = it
onDismissDrag(it)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
// Same direction-locked drag pattern as ZoomableAsyncImage:
// horizontal drags fall through to the parent pager,
// vertical drags drive swipe-to-dismiss.
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
var totalDx = 0f
var totalDy = 0f
var verticalLocked = false
val velocityTracker = VelocityTracker()
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
if (event.changes.size != 1) return@awaitEachGesture
val change = event.changes.first()
val delta = change.positionChange()
if (!verticalLocked) {
totalDx += delta.x
totalDy += delta.y
val crossed = abs(totalDx) > touchSlop || abs(totalDy) > touchSlop
if (crossed) {
if (abs(totalDy) > abs(totalDx) && totalDy > 0f) {
verticalLocked = true
scope.launch { dragYAnim.snapTo(totalDy) }
velocityTracker.addPosition(change.uptimeMillis, change.position)
change.consume()
} else {
return@awaitEachGesture
}
}
} else {
val next = (dragY + delta.y).coerceAtLeast(0f)
scope.launch { dragYAnim.snapTo(next) }
velocityTracker.addPosition(change.uptimeMillis, change.position)
change.consume()
}
if (!change.pressed) {
if (verticalLocked) {
// Project release velocity 0.3s forward (matches iOS
// `predictedEndTranslation`) so a quick down-flick
// dismisses even when raw distance is short.
val velocityY = velocityTracker.calculateVelocity().y
val projectedY = dragY + velocityY * 0.3f
if (projectedY >= 120f) onSwipeDownDismiss()
else scope.launch { dragYAnim.animateTo(0f) }
}
return@awaitEachGesture
}
}
}
}
) {
if (posterModel != null) {
coil3.compose.AsyncImage(
model = posterModel,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(translationY = dragY)
)
}
Box(
modifier = Modifier
.align(Alignment.Center)
.size(80.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.6f))
.clickable { onPlay() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play video",
tint = Color.White,
modifier = Modifier.size(40.dp)
)
}
}
}
@@ -0,0 +1,153 @@
package com.darkwisp.app.ui.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
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.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
/**
* Inline carousel for posts containing two or more media items. Mirrors the
* iOS `MediaGridView`: a horizontally paged row of 4:5 tiles, ~70% of the
* post width with the next tile peeking from the right edge. Tap a tile to
* open a full-screen viewer.
*
* Videos render as their thumbhash/blurhash placeholder with a play overlay;
* tapping invokes [onFullScreenVideo] which routes to the existing single-
* video full-screen player. Tapping an image tile invokes [onFullScreenImage]
* with the image-only URL list and the position of the tapped item in that
* list, letting the caller open a multi-image pager at the right page.
*/
internal sealed interface CarouselItem {
val meta: MediaMeta
data class Image(override val meta: MediaMeta) : CarouselItem
data class Video(override val meta: MediaMeta) : CarouselItem
data class Unknown(override val meta: MediaMeta) : CarouselItem
}
@Composable
internal fun MediaCarousel(
items: List<CarouselItem>,
onOpenPager: (startIndex: Int) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
val pagerState = rememberPagerState(pageCount = { items.size })
// Tile is sized so the next one peeks ~48dp from the right edge. Using
// PageSize.Fixed (rather than contentPadding) means the pager naturally
// snaps the last page flush to the right edge instead of leaving an empty
// strip after the final image.
val peekWidth = 48.dp
val pageSpacing = 8.dp
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val tileWidth = maxWidth - peekWidth - pageSpacing
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(tileWidth),
pageSpacing = pageSpacing,
modifier = Modifier.fillMaxWidth()
) { page ->
CarouselTile(
item = items[page],
onTap = { onOpenPager(page) }
)
}
if (items.size > 1) {
Text(
text = "${pagerState.currentPage + 1} / ${items.size}",
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 12.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.55f))
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
@Composable
private fun CarouselTile(
item: CarouselItem,
onTap: () -> Unit
) {
val meta = item.meta
val placeholder = rememberMediaPlaceholderPainter(meta.thumbhash, meta.blurhash, meta.dimension)
val tileShape = RoundedCornerShape(12.dp)
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 5f)
.clip(tileShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { onTap() },
contentAlignment = Alignment.Center
) {
when (item) {
is CarouselItem.Image, is CarouselItem.Unknown -> {
AsyncImage(
model = meta.url,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = placeholder,
error = placeholder,
modifier = Modifier.fillMaxSize()
)
}
is CarouselItem.Video -> {
if (placeholder != null) {
Image(
painter = placeholder,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play video",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
}
}
}
}
@@ -131,7 +131,9 @@ private const val INLINE_CONTENT_TAG = "androidx.compose.foundation.text.inlineC
data class MediaSettings(
val autoLoadMedia: Boolean = true,
val videoAutoPlay: Boolean = true
val videoAutoPlay: Boolean = true,
val mediaLayoutStyle: com.darkwisp.app.repo.InterfacePreferences.MediaLayoutStyle =
com.darkwisp.app.repo.InterfacePreferences.MediaLayoutStyle.GALLERY
)
val LocalMediaSettings = compositionLocalOf { MediaSettings() }
@@ -619,36 +621,102 @@ fun RichContent(
) {
val segments = remember(content, emojiMap, imetaMap, plainLinks) { parseContent(content.trimEnd('\n', '\r'), emojiMap, imetaMap, trimBlankLines = !plainLinks) }
val profileVer = eventRepo?.profileVersion?.collectAsState()?.value ?: 0
var fullScreenImageUrl by remember { mutableStateOf<String?>(null) }
var fullScreenPager by remember { mutableStateOf<Pair<List<MediaPagerItem>, Int>?>(null) }
val mediaLayoutStyle = LocalMediaSettings.current.mediaLayoutStyle
val galleryMode = mediaLayoutStyle == com.darkwisp.app.repo.InterfacePreferences.MediaLayoutStyle.GALLERY
if (fullScreenImageUrl != null) {
FullScreenImageViewer(
imageUrl = fullScreenImageUrl!!,
onDismiss = { fullScreenImageUrl = null }
// Every image/video/unknown-media URL in the post, in order. Tapping
// any inline media (stack mode) or any tile (gallery mode) opens the
// swipeable pager at that item's position — image pages get pinch-zoom,
// video pages get a play overlay that hands off to FullScreenVideoState.
val allMediaItems = remember(segments) {
segments.mapNotNull { seg ->
when (seg) {
is ContentSegment.ImageSegment -> MediaPagerItem.Image(seg.meta.url)
is ContentSegment.UnknownMediaSegment -> MediaPagerItem.Image(seg.meta.url)
is ContentSegment.VideoSegment -> MediaPagerItem.Video(seg.meta.url, posterModel = seg.meta.url)
else -> null
}
}
}
val openPagerFor: (String) -> Unit = { url ->
val idx = allMediaItems.indexOfFirst { it.url == url }
if (idx >= 0) fullScreenPager = allMediaItems to idx
}
fullScreenPager?.let { (mediaItems, idx) ->
FullScreenMediaPager(
items = mediaItems,
initialPage = idx,
onDismiss = { fullScreenPager = null }
)
}
val groups = remember(segments, plainLinks) {
val built = mutableListOf<Any>() // Either List<ContentSegment> (inline run) or ContentSegment (block)
val groups = remember(segments, plainLinks, galleryMode) {
val built = mutableListOf<Any>() // List<ContentSegment> (inline run), List<CarouselItem> (carousel run), or ContentSegment
fun isInline(s: ContentSegment) = s is ContentSegment.TextSegment ||
s is ContentSegment.HashtagSegment ||
s is ContentSegment.NostrProfileSegment ||
s is ContentSegment.CustomEmojiSegment ||
s is ContentSegment.InlineLinkSegment ||
(plainLinks && s is ContentSegment.LinkSegment)
fun toCarouselItem(s: ContentSegment): CarouselItem? = when (s) {
is ContentSegment.ImageSegment -> CarouselItem.Image(s.meta)
is ContentSegment.VideoSegment -> CarouselItem.Video(s.meta)
is ContentSegment.UnknownMediaSegment -> CarouselItem.Unknown(s.meta)
else -> null
}
// Whitespace-only text between media is treated as a joiner so two
// image URLs separated by `\n\n` still group into one carousel.
fun isWhitespaceText(s: ContentSegment): Boolean =
s is ContentSegment.TextSegment && s.text.all { it.isWhitespace() }
for (segment in segments) {
if (isInline(segment)) {
val last = built.lastOrNull()
if (last is MutableList<*>) {
@Suppress("UNCHECKED_CAST")
(last as MutableList<ContentSegment>).add(segment)
// Single pass over the original segments, mirroring iOS
// `RichContentView.groupSegments`. Media runs of 2+ collapse into a
// carousel; inline runs accumulate into one Text block; everything
// else lands as a standalone block segment.
var i = 0
while (i < segments.size) {
val seg = segments[i]
val asCarousel = toCarouselItem(seg)
if (galleryMode && asCarousel != null) {
val run = mutableListOf<CarouselItem>(asCarousel)
var j = i + 1
while (j < segments.size) {
val next = segments[j]
val nextCarousel = toCarouselItem(next)
if (nextCarousel != null) {
run.add(nextCarousel)
j++
} else if (isWhitespaceText(next) &&
j + 1 < segments.size &&
toCarouselItem(segments[j + 1]) != null
) {
j++ // skip whitespace joiner between two media items
} else {
break
}
}
if (run.size >= 2) {
built.add(run)
} else {
built.add(mutableListOf(segment))
built.add(seg) // single item stays standalone
}
i = j
continue
}
if (isInline(seg)) {
val last = built.lastOrNull()
if (last is MutableList<*> && last.firstOrNull() is ContentSegment) {
@Suppress("UNCHECKED_CAST")
(last as MutableList<ContentSegment>).add(seg)
} else {
built.add(mutableListOf<ContentSegment>(seg))
}
} else {
built.add(segment)
built.add(seg)
}
i++
}
built
}
@@ -664,8 +732,24 @@ fun RichContent(
androidx.compose.runtime.CompositionLocalProvider(
LocalAudioPostContext provides AudioPostContext(authorPubkey = authorPubkey, eventRepo = eventRepo)
) {
Column(modifier = modifier) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (group in groups) {
if (group is List<*> && group.firstOrNull() is CarouselItem) {
@Suppress("UNCHECKED_CAST")
val carouselItems = group as List<CarouselItem>
MediaCarousel(
items = carouselItems,
onOpenPager = { localIdx ->
// Translate the carousel-local index into the
// post-wide media index so the pager opens on the
// right page even if the post has other media
// segments outside this carousel run.
val tappedUrl = carouselItems[localIdx].meta.url
openPagerFor(tappedUrl)
}
)
continue
}
if (group is List<*>) {
@Suppress("UNCHECKED_CAST")
val inlineSegments = group as List<ContentSegment>
@@ -811,7 +895,7 @@ fun RichContent(
is ContentSegment.ImageSegment -> {
ImageWithContextMenu(
meta = segment.meta,
onFullScreen = { fullScreenImageUrl = segment.meta.url }
onFullScreen = { openPagerFor(segment.meta.url) }
)
}
is ContentSegment.VideoSegment -> {
@@ -832,7 +916,7 @@ fun RichContent(
is ContentSegment.UnknownMediaSegment -> {
UnknownMediaContent(
meta = segment.meta,
onFullScreenImage = { fullScreenImageUrl = segment.meta.url },
onFullScreenImage = { openPagerFor(segment.meta.url) },
onFullScreenVideo = { positionMs ->
FullScreenVideoState.enter(segment.meta.url, positionMs)
}
@@ -0,0 +1,240 @@
package com.darkwisp.app.ui.component
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.IntSize
import coil3.compose.AsyncImage
import kotlin.math.abs
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
/**
* Reusable zoomable image surface that mirrors the iOS `FullScreenImageView`
* gesture model. One unified gesture loop owns all drag/pinch handling so
* single-finger horizontal drags can fall through to a parent HorizontalPager
* that's the only way to coexist with a Compose pager, since the stock
* [detectTransformGestures] consumes single-finger pan unconditionally once
* touch slop is crossed.
*
* Behavior:
* - Pinch to zoom 1x..[maxScale]; release snaps back if user pinched below 1x
* - Double-tap toggles 1x 2x with the tap location pinned under the finger
* - When zoomed, single-finger drag pans within the image bounds (clamped)
* - When zoom is 1x, vertically-dominant downward drag fires
* [onSwipeDownDismiss] once it passes [dismissThreshold] on release;
* while dragging, [onDismissDrag] reports the vertical offset
* - Horizontal single-finger drag at 1x is NOT consumed, so a parent pager
* can use it for paging
*/
@Composable
internal fun ZoomableAsyncImage(
model: Any?,
contentDescription: String?,
onSwipeDownDismiss: () -> Unit,
onDismissDrag: (yOffset: Float) -> Unit,
modifier: Modifier = Modifier,
maxScale: Float = 4f,
dismissThreshold: Float = 120f
) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var boxSize by remember { mutableStateOf(IntSize.Zero) }
val scope = rememberCoroutineScope()
val touchSlop = LocalViewConfiguration.current.touchSlop
// Vertical-drag offset (only used when scale == 1). Driven by an
// Animatable so the failed-dismiss snap-back is a spring rather than a
// jump cut.
val dragYAnim = remember { Animatable(0f) }
var dragY by remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
snapshotFlow { dragYAnim.value }
.distinctUntilChanged()
.collect {
dragY = it
onDismissDrag(it)
}
}
fun clampOffset(target: Offset, s: Float): Offset {
if (s <= 1f || boxSize == IntSize.Zero) return Offset.Zero
val maxX = (boxSize.width * (s - 1f)) / 2f
val maxY = (boxSize.height * (s - 1f)) / 2f
return Offset(
x = target.x.coerceIn(-maxX, maxX),
y = target.y.coerceIn(-maxY, maxY)
)
}
Box(
modifier = modifier
.fillMaxSize()
.onSizeChanged { boxSize = it }
) {
AsyncImage(
model = model,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y + dragY
)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { tapOffset ->
scope.launch {
if (scale > 1f) {
scale = 1f
offset = Offset.Zero
} else {
val newScale = 2f
val centerX = boxSize.width / 2f
val centerY = boxSize.height / 2f
val target = Offset(
x = (centerX - tapOffset.x) * (newScale - 1f),
y = (centerY - tapOffset.y) * (newScale - 1f)
)
scale = newScale
offset = clampOffset(target, newScale)
}
}
}
)
}
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
var totalDx = 0f
var totalDy = 0f
var mode: Mode = Mode.Undetermined
// Track release velocity so a quick down-flick
// dismisses even if the finger didn't travel the
// full 120px threshold. Matches iOS's
// `predictedEndTranslation` (translation + 0.3s ·
// velocity).
val velocityTracker = VelocityTracker()
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
val activeCount = event.changes.count { it.pressed }
if (activeCount == 0) {
if (mode == Mode.SwipeDown) {
val velocityY = velocityTracker.calculateVelocity().y
val projectedY = dragY + velocityY * 0.3f
if (projectedY >= dismissThreshold) {
onSwipeDownDismiss()
} else {
scope.launch { dragYAnim.animateTo(0f) }
}
}
return@awaitEachGesture
}
if (activeCount >= 2) {
// Pinch — once two fingers are down, we own
// the gesture for zoom + pan.
if (mode != Mode.Pinch) mode = Mode.Pinch
val zoomChange = event.calculateZoom()
val panChange = event.calculatePan()
if (zoomChange != 1f || panChange != Offset.Zero) {
val newScale = (scale * zoomChange).coerceIn(1f, maxScale)
scale = newScale
offset = if (newScale > 1f) {
clampOffset(offset + panChange, newScale)
} else {
Offset.Zero
}
event.changes.forEach { if (it.positionChanged()) it.consume() }
}
continue
}
// Single finger
val change = event.changes.firstOrNull { it.pressed } ?: continue
val delta = change.positionChange()
when (mode) {
Mode.Undetermined -> {
totalDx += delta.x
totalDy += delta.y
val crossed = abs(totalDx) > touchSlop || abs(totalDy) > touchSlop
if (!crossed) continue
when {
scale > 1f -> {
mode = Mode.Pan
offset = clampOffset(offset + Offset(totalDx, totalDy), scale)
change.consume()
}
abs(totalDy) > abs(totalDx) && totalDy > 0f -> {
mode = Mode.SwipeDown
scope.launch { dragYAnim.snapTo(totalDy) }
velocityTracker.addPosition(change.uptimeMillis, change.position)
change.consume()
}
else -> {
// Horizontal at 1x — abandon this gesture so the
// parent HorizontalPager can take it. We do not
// consume any events.
return@awaitEachGesture
}
}
}
Mode.Pan -> {
offset = clampOffset(offset + delta, scale)
change.consume()
}
Mode.SwipeDown -> {
val next = (dragY + delta.y).coerceAtLeast(0f)
scope.launch { dragYAnim.snapTo(next) }
velocityTracker.addPosition(change.uptimeMillis, change.position)
change.consume()
}
Mode.Pinch -> {
// Pinch dropped to one finger — keep what we have
// until release. Could switch to pan if zoomed, but
// that adds more state for marginal value.
if (scale > 1f) {
offset = clampOffset(offset + delta, scale)
change.consume()
}
}
}
}
}
}
)
}
}
private enum class Mode { Undetermined, Pinch, Pan, SwipeDown }
@@ -99,6 +99,7 @@ fun InterfaceScreen(
var clientTagEnabled by remember { mutableStateOf(interfacePrefs.isClientTagEnabled()) }
var autoLoadMedia by remember { mutableStateOf(interfacePrefs.isAutoLoadMedia()) }
var videoAutoPlay by remember { mutableStateOf(interfacePrefs.isVideoAutoPlay()) }
var mediaLayout by remember { mutableStateOf(interfacePrefs.getMediaLayoutStyle()) }
var liveStreamsHidden by remember { mutableStateOf(interfacePrefs.isLiveStreamsHidden()) }
var selectedTheme by remember { mutableStateOf(interfacePrefs.getTheme()) }
var isCustomTheme by remember { mutableStateOf(selectedTheme == "custom") }
@@ -461,6 +462,38 @@ fun InterfaceScreen(
)
}
Spacer(Modifier.height(12.dp))
Column(modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.settings_media_layout), style = MaterialTheme.typography.bodyMedium)
Text(
stringResource(R.string.settings_media_layout_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(8.dp))
val mediaLayoutOptions = listOf(
InterfacePreferences.MediaLayoutStyle.GALLERY to R.string.settings_media_layout_gallery,
InterfacePreferences.MediaLayoutStyle.STACK to R.string.settings_media_layout_stack
)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
mediaLayoutOptions.forEachIndexed { index, (style, labelRes) ->
SegmentedButton(
selected = mediaLayout == style,
onClick = {
mediaLayout = style
interfacePrefs.setMediaLayoutStyle(style)
onChanged()
},
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = mediaLayoutOptions.size
)
) {
Text(stringResource(labelRes))
}
}
}
}
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
+4
View File
@@ -504,6 +504,10 @@
<string name="settings_auto_load_description">Automatically download images and videos in notes. When off, tap to load.</string>
<string name="settings_video_autoplay">Video autoplay</string>
<string name="settings_video_autoplay_description">Automatically play videos when they scroll into view</string>
<string name="settings_media_layout">Multi-image layout</string>
<string name="settings_media_layout_description">Gallery: horizontal swipe through every photo and video. Stack: each item full-width below the next.</string>
<string name="settings_media_layout_gallery">Gallery</string>
<string name="settings_media_layout_stack">Stack</string>
<string name="settings_hide_live_streams">Hide live streams</string>
<string name="settings_hide_live_streams_description">Hide the live streams row from the top of your feed</string>
<string name="settings_client_tag">Client Tag</string>