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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user