feat:增加上传视频&视频播放等功能
This commit is contained in:
@@ -13,11 +13,11 @@ android {
|
||||
applicationId = "com.memory.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 44
|
||||
versionName = "1.6.0"
|
||||
versionCode = 53
|
||||
versionName = "1.6.9"
|
||||
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://x.amos.us.kg/api/\"")
|
||||
buildConfigField("int", "VERSION_CODE", "44")
|
||||
buildConfigField("int", "VERSION_CODE", "53")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -82,6 +82,11 @@ dependencies {
|
||||
|
||||
// Image loading
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
implementation("io.coil-kt:coil-video:2.7.0")
|
||||
|
||||
// Video player - 使用更新的版本避免兼容性问题
|
||||
implementation("androidx.media3:media3-exoplayer:1.4.1")
|
||||
implementation("androidx.media3:media3-ui:1.4.1")
|
||||
|
||||
// DataStore
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
|
||||
@@ -44,8 +44,11 @@ class MainActivity : ComponentActivity() {
|
||||
credentialsManager = CredentialsManager(this)
|
||||
updateManager = UpdateManager(this)
|
||||
|
||||
// 配置 Coil 图片加载器
|
||||
// 配置 Coil 图片加载器(支持视频帧提取)
|
||||
val imageLoader = ImageLoader.Builder(this)
|
||||
.components {
|
||||
add(coil.decode.VideoFrameDecoder.Factory())
|
||||
}
|
||||
.okHttpClient {
|
||||
OkHttpClient.Builder()
|
||||
.build()
|
||||
|
||||
@@ -3,11 +3,17 @@ package com.memory.app.ui.components
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.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 coil.compose.AsyncImage
|
||||
@@ -23,16 +29,14 @@ fun MediaGrid(
|
||||
|
||||
when (media.size) {
|
||||
1 -> {
|
||||
AsyncImage(
|
||||
model = media[0].mediaUrl,
|
||||
contentDescription = null,
|
||||
MediaItem(
|
||||
media = media[0],
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 10f)
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(0) },
|
||||
contentScale = ContentScale.Crop
|
||||
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(0) }
|
||||
)
|
||||
}
|
||||
2 -> {
|
||||
@@ -44,15 +48,13 @@ fun MediaGrid(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
media.forEachIndexed { index, item ->
|
||||
AsyncImage(
|
||||
model = item.mediaUrl,
|
||||
contentDescription = null,
|
||||
MediaItem(
|
||||
media = item,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) },
|
||||
contentScale = ContentScale.Crop
|
||||
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -65,30 +67,26 @@ fun MediaGrid(
|
||||
.clip(shape),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = media[0].mediaUrl,
|
||||
contentDescription = null,
|
||||
MediaItem(
|
||||
media = media[0],
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(0) },
|
||||
contentScale = ContentScale.Crop
|
||||
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(0) }
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
for (i in 1..2) {
|
||||
AsyncImage(
|
||||
model = media[i].mediaUrl,
|
||||
contentDescription = null,
|
||||
MediaItem(
|
||||
media = media[i],
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(i) },
|
||||
contentScale = ContentScale.Crop
|
||||
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(i) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -115,15 +113,13 @@ fun MediaGrid(
|
||||
for (col in 0 until columns) {
|
||||
val index = row * columns + col
|
||||
if (index < media.size) {
|
||||
AsyncImage(
|
||||
model = media[index].mediaUrl,
|
||||
contentDescription = null,
|
||||
MediaItem(
|
||||
media = media[index],
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) },
|
||||
contentScale = ContentScale.Crop
|
||||
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) }
|
||||
)
|
||||
} else {
|
||||
// 占位,保持布局
|
||||
@@ -136,3 +132,58 @@ fun MediaGrid(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaItem(
|
||||
media: Media,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
|
||||
Box(modifier = modifier) {
|
||||
// 对于视频,使用特殊配置提取帧
|
||||
val imageRequest = if (media.mediaType == "video") {
|
||||
coil.request.ImageRequest.Builder(context)
|
||||
.data(media.mediaUrl)
|
||||
.crossfade(true)
|
||||
.build()
|
||||
} else {
|
||||
coil.request.ImageRequest.Builder(context)
|
||||
.data(media.mediaUrl)
|
||||
.crossfade(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// 如果是视频,显示播放按钮
|
||||
if (media.mediaType == "video") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.3f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.9f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = "播放视频",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = Color.Black
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,10 +291,10 @@ fun PostCard(
|
||||
)
|
||||
}
|
||||
|
||||
// Image Viewer
|
||||
// Image/Video Viewer
|
||||
showImageViewerIndex?.let { initialIndex ->
|
||||
FullScreenImageViewer(
|
||||
imageUrls = post.media.map { it.mediaUrl },
|
||||
FullScreenMediaViewer(
|
||||
media = post.media,
|
||||
initialIndex = initialIndex,
|
||||
onDismiss = { showImageViewerIndex = null }
|
||||
)
|
||||
@@ -1026,14 +1026,14 @@ fun EditPostDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FullScreenImageViewer(
|
||||
imageUrls: List<String>,
|
||||
fun FullScreenMediaViewer(
|
||||
media: List<com.memory.app.data.model.Media>,
|
||||
initialIndex: Int = 0,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialIndex,
|
||||
pageCount = { imageUrls.size }
|
||||
pageCount = { media.size }
|
||||
)
|
||||
|
||||
// 跟踪当前页面是否处于缩放状态
|
||||
@@ -1051,18 +1051,26 @@ fun FullScreenImageViewer(
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
// 图片分页器
|
||||
// 媒体分页器
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
userScrollEnabled = !isCurrentPageZoomed,
|
||||
key = { it }
|
||||
) { page ->
|
||||
ZoomableImage(
|
||||
imageUrl = imageUrls[page],
|
||||
onTap = onDismiss,
|
||||
onScaleChanged = { scale -> zoomStates[page] = scale }
|
||||
)
|
||||
val item = media[page]
|
||||
if (item.mediaType == "video") {
|
||||
VideoPlayer(
|
||||
videoUrl = item.mediaUrl,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
ZoomableImage(
|
||||
imageUrl = item.mediaUrl,
|
||||
onTap = onDismiss,
|
||||
onScaleChanged = { scale -> zoomStates[page] = scale }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭按钮
|
||||
@@ -1081,8 +1089,8 @@ fun FullScreenImageViewer(
|
||||
)
|
||||
}
|
||||
|
||||
// 页码指示器 (多张图片时显示)
|
||||
if (imageUrls.size > 1) {
|
||||
// 页码指示器 (多个媒体时显示)
|
||||
if (media.size > 1) {
|
||||
// 底部圆点指示器
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -1090,7 +1098,7 @@ fun FullScreenImageViewer(
|
||||
.padding(bottom = 32.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
repeat(imageUrls.size) { index ->
|
||||
repeat(media.size) { index ->
|
||||
val isSelected = pagerState.currentPage == index
|
||||
val color by animateColorAsState(
|
||||
targetValue = if (isSelected) Color.White else Color.White.copy(alpha = 0.4f),
|
||||
@@ -1110,6 +1118,25 @@ fun FullScreenImageViewer(
|
||||
}
|
||||
}
|
||||
|
||||
// 保留旧的函数名以兼容
|
||||
@Composable
|
||||
fun FullScreenImageViewer(
|
||||
imageUrls: List<String>,
|
||||
initialIndex: Int = 0,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val media = imageUrls.map { url ->
|
||||
com.memory.app.data.model.Media(
|
||||
id = 0,
|
||||
postId = 0,
|
||||
mediaUrl = url,
|
||||
mediaType = "image",
|
||||
sortOrder = 0
|
||||
)
|
||||
}
|
||||
FullScreenMediaViewer(media = media, initialIndex = initialIndex, onDismiss = onDismiss)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZoomableImage(
|
||||
imageUrl: String,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.memory.app.ui.components
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
|
||||
@Composable
|
||||
fun VideoPlayer(
|
||||
videoUrl: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(MediaItem.fromUri(videoUrl))
|
||||
prepare()
|
||||
playWhenReady = true
|
||||
|
||||
addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
isLoading = playbackState == Player.STATE_BUFFERING
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
useController = true
|
||||
// 设置控制器显示时间和行为
|
||||
controllerShowTimeoutMs = 3000
|
||||
controllerHideOnTouch = true
|
||||
controllerAutoShow = true
|
||||
// 使用默认控制器,ExoPlayer 的默认控制器已经在底部
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,15 @@ fun MainNavigation(
|
||||
val notifications by notificationViewModel.notifications.collectAsState()
|
||||
val notificationLoading by notificationViewModel.isLoading.collectAsState()
|
||||
val uploadProgress by homeViewModel.uploadProgress.collectAsState()
|
||||
val errorMessage by homeViewModel.errorMessage.collectAsState()
|
||||
|
||||
// 显示错误提示
|
||||
errorMessage?.let { message ->
|
||||
LaunchedEffect(message) {
|
||||
android.widget.Toast.makeText(context, message, android.widget.Toast.LENGTH_SHORT).show()
|
||||
homeViewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(user) {
|
||||
// 用户变化时清空旧数据并重新加载
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.MusicNote
|
||||
import androidx.compose.material.icons.outlined.Mood
|
||||
import androidx.compose.material.icons.outlined.Public
|
||||
import androidx.compose.material.icons.outlined.Videocam
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -56,6 +57,7 @@ fun CreatePostScreen(
|
||||
|
||||
var content by remember { mutableStateOf("") }
|
||||
var selectedImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||
var selectedVideos by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||
var visibility by remember { mutableStateOf(0) } // 0=所有人可见, 1=仅自己可见
|
||||
var musicUrl by remember { mutableStateOf("") }
|
||||
@@ -64,14 +66,17 @@ fun CreatePostScreen(
|
||||
var showMusicInput by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 计算还能选多少张
|
||||
val maxSelectable = (9 - selectedImages.size).coerceAtLeast(0)
|
||||
|
||||
val imagePicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9)
|
||||
) { uris ->
|
||||
selectedImages = (selectedImages + uris).take(9)
|
||||
}
|
||||
|
||||
val videoPicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9)
|
||||
) { uris ->
|
||||
selectedVideos = (selectedVideos + uris).take(9)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -103,7 +108,7 @@ fun CreatePostScreen(
|
||||
|
||||
// Publish button
|
||||
Button(
|
||||
onClick = { onPost(content, selectedImages, visibility, musicUrl.ifBlank { null }) },
|
||||
onClick = { onPost(content, selectedImages + selectedVideos, visibility, musicUrl.ifBlank { null }) },
|
||||
enabled = content.isNotBlank() && !isLoading,
|
||||
shape = RoundedCornerShape(50),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
@@ -198,22 +203,28 @@ fun CreatePostScreen(
|
||||
)
|
||||
|
||||
// Selected Images - 九宫格布局
|
||||
if (selectedImages.isNotEmpty()) {
|
||||
val allMedia = selectedImages.map { MediaItemType.Image(it) } + selectedVideos.map { MediaItemType.Video(it) }
|
||||
if (allMedia.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val columns = if (selectedImages.size <= 3) selectedImages.size else 3
|
||||
val rows = (selectedImages.size + columns - 1) / columns
|
||||
val columns = if (allMedia.size <= 3) allMedia.size else 3
|
||||
val rows = (allMedia.size + columns - 1) / columns
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (row in 0 until rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (col in 0 until columns) {
|
||||
val index = row * columns + col
|
||||
if (index < selectedImages.size) {
|
||||
val uri = selectedImages[index]
|
||||
if (index < allMedia.size) {
|
||||
val item = allMedia[index]
|
||||
val progress = uploadProgress[index]
|
||||
SelectedImageItem(
|
||||
uri = uri,
|
||||
onRemove = if (!isLoading) {{ selectedImages = selectedImages - uri }} else null,
|
||||
SelectedMediaItem(
|
||||
item = item,
|
||||
onRemove = if (!isLoading) {{
|
||||
when (item) {
|
||||
is MediaItemType.Image -> selectedImages = selectedImages - item.uri
|
||||
is MediaItemType.Video -> selectedVideos = selectedVideos - item.uri
|
||||
}
|
||||
}} else null,
|
||||
uploadProgress = progress
|
||||
)
|
||||
}
|
||||
@@ -303,13 +314,31 @@ fun CreatePostScreen(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
},
|
||||
enabled = selectedImages.size < 9,
|
||||
enabled = (selectedImages.size + selectedVideos.size) < 9,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Image,
|
||||
contentDescription = "添加图片",
|
||||
tint = if (selectedImages.size < 9) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
tint = if ((selectedImages.size + selectedVideos.size) < 9) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Video Button
|
||||
IconButton(
|
||||
onClick = {
|
||||
videoPicker.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)
|
||||
)
|
||||
},
|
||||
enabled = (selectedImages.size + selectedVideos.size) < 9,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Videocam,
|
||||
contentDescription = "添加视频",
|
||||
tint = if ((selectedImages.size + selectedVideos.size) < 9) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
@@ -438,15 +467,24 @@ fun CreatePostScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体项类型
|
||||
private sealed class MediaItemType {
|
||||
data class Image(val uri: Uri) : MediaItemType()
|
||||
data class Video(val uri: Uri) : MediaItemType()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedImageItem(
|
||||
uri: Uri,
|
||||
private fun SelectedMediaItem(
|
||||
item: MediaItemType,
|
||||
onRemove: (() -> Unit)?,
|
||||
uploadProgress: Float? = null
|
||||
) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = uri,
|
||||
model = when (item) {
|
||||
is MediaItemType.Image -> item.uri
|
||||
is MediaItemType.Video -> item.uri
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(90.dp)
|
||||
@@ -454,6 +492,35 @@ private fun SelectedImageItem(
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// 视频标识
|
||||
if (item is MediaItemType.Video) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(6.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color.Black.copy(alpha = 0.7f))
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Videocam,
|
||||
contentDescription = "视频",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
androidx.compose.material3.Text(
|
||||
text = "视频",
|
||||
fontSize = 10.sp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上传进度遮罩
|
||||
if (uploadProgress != null && uploadProgress < 1f) {
|
||||
Box(
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Mood
|
||||
import androidx.compose.material.icons.outlined.MusicNote
|
||||
import androidx.compose.material.icons.outlined.Public
|
||||
import androidx.compose.material.icons.outlined.Videocam
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -55,8 +56,9 @@ fun EditPostScreen(
|
||||
}
|
||||
|
||||
var content by remember { mutableStateOf(post.content) }
|
||||
var existingImages by remember { mutableStateOf(post.media.map { it.mediaUrl }) }
|
||||
var existingImages by remember { mutableStateOf(post.media.map { it.mediaUrl to (it.mediaType == "video") }) }
|
||||
var newImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||
var newVideos by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||
var visibility by remember { mutableStateOf(post.visibility) }
|
||||
var musicUrl by remember { mutableStateOf(post.music?.shareUrl ?: "") }
|
||||
@@ -67,7 +69,7 @@ fun EditPostScreen(
|
||||
// 标记是否有原始音乐(用于判断是删除还是新增)
|
||||
val hadOriginalMusic = post.music != null
|
||||
|
||||
val totalImages = existingImages.size + newImages.size
|
||||
val totalImages = existingImages.size + newImages.size + newVideos.size
|
||||
|
||||
val imagePicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9)
|
||||
@@ -75,6 +77,13 @@ fun EditPostScreen(
|
||||
val remaining = 9 - totalImages
|
||||
newImages = (newImages + uris).take(remaining.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
val videoPicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9)
|
||||
) { uris ->
|
||||
val remaining = 9 - totalImages
|
||||
newVideos = (newVideos + uris).take(remaining.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -114,7 +123,7 @@ fun EditPostScreen(
|
||||
hadOriginalMusic && musicUrl != post.music?.shareUrl -> musicUrl // 修改音乐(虽然目前不太可能)
|
||||
else -> null // 不修改
|
||||
}
|
||||
onSave(content, existingImages, newImages, visibility, musicParam)
|
||||
onSave(content, existingImages.map { it.first }, newImages + newVideos, visibility, musicParam)
|
||||
},
|
||||
enabled = content.isNotBlank() && !isLoading,
|
||||
shape = RoundedCornerShape(50),
|
||||
@@ -194,7 +203,9 @@ fun EditPostScreen(
|
||||
)
|
||||
|
||||
// Images - 合并已有图片和新图片,九宫格布局
|
||||
val allImages = existingImages.map { ImageItem.Existing(it) } + newImages.mapIndexed { idx, uri -> ImageItem.New(uri, idx) }
|
||||
val allImages = existingImages.map { (url, isVideo) -> ImageItem.Existing(url, isVideo) } +
|
||||
newImages.map { ImageItem.NewImage(it) } +
|
||||
newVideos.map { ImageItem.NewVideo(it) }
|
||||
if (allImages.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val columns = if (allImages.size <= 3) allImages.size else 3
|
||||
@@ -208,15 +219,23 @@ fun EditPostScreen(
|
||||
if (index < allImages.size) {
|
||||
val item = allImages[index]
|
||||
val progress = when (item) {
|
||||
is ImageItem.New -> uploadProgress[item.index]
|
||||
is ImageItem.NewImage -> {
|
||||
val newImageIndex = newImages.indexOf(item.uri)
|
||||
if (newImageIndex >= 0) uploadProgress[existingImages.size + newImageIndex] else null
|
||||
}
|
||||
is ImageItem.NewVideo -> {
|
||||
val newVideoIndex = newVideos.indexOf(item.uri)
|
||||
if (newVideoIndex >= 0) uploadProgress[existingImages.size + newImages.size + newVideoIndex] else null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
EditImageItem(
|
||||
item = item,
|
||||
onRemove = if (!isLoading) {{
|
||||
when (item) {
|
||||
is ImageItem.Existing -> existingImages = existingImages - item.url
|
||||
is ImageItem.New -> newImages = newImages - item.uri
|
||||
is ImageItem.Existing -> existingImages = existingImages.filter { it.first != item.url }
|
||||
is ImageItem.NewImage -> newImages = newImages - item.uri
|
||||
is ImageItem.NewVideo -> newVideos = newVideos - item.uri
|
||||
}
|
||||
}} else null,
|
||||
uploadProgress = progress
|
||||
@@ -314,6 +333,24 @@ fun EditPostScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Video Button
|
||||
IconButton(
|
||||
onClick = {
|
||||
videoPicker.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)
|
||||
)
|
||||
},
|
||||
enabled = totalImages < 9,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Videocam,
|
||||
contentDescription = "添加视频",
|
||||
tint = if (totalImages < 9) Brand500 else Slate300,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Music Button
|
||||
IconButton(
|
||||
onClick = { showMusicInput = true },
|
||||
@@ -418,8 +455,9 @@ fun EditPostScreen(
|
||||
|
||||
// 图片项类型
|
||||
private sealed class ImageItem {
|
||||
data class Existing(val url: String) : ImageItem()
|
||||
data class New(val uri: Uri, val index: Int) : ImageItem()
|
||||
data class Existing(val url: String, val isVideo: Boolean) : ImageItem()
|
||||
data class NewImage(val uri: Uri) : ImageItem()
|
||||
data class NewVideo(val uri: Uri) : ImageItem()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -432,7 +470,8 @@ private fun EditImageItem(
|
||||
AsyncImage(
|
||||
model = when (item) {
|
||||
is ImageItem.Existing -> item.url
|
||||
is ImageItem.New -> item.uri
|
||||
is ImageItem.NewImage -> item.uri
|
||||
is ImageItem.NewVideo -> item.uri
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
@@ -441,6 +480,40 @@ private fun EditImageItem(
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// 视频标识
|
||||
val isVideo = when (item) {
|
||||
is ImageItem.Existing -> item.isVideo
|
||||
is ImageItem.NewVideo -> true
|
||||
else -> false
|
||||
}
|
||||
if (isVideo) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(6.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color.Black.copy(alpha = 0.7f))
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Videocam,
|
||||
contentDescription = "视频",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "视频",
|
||||
fontSize = 10.sp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上传进度遮罩
|
||||
if (uploadProgress != null && uploadProgress < 1f) {
|
||||
Box(
|
||||
|
||||
@@ -27,6 +27,7 @@ import coil.compose.AsyncImage
|
||||
import com.memory.app.data.model.Comment
|
||||
import com.memory.app.data.model.Post
|
||||
import com.memory.app.ui.components.EmojiPickerDialog
|
||||
import com.memory.app.ui.components.FullScreenMediaViewer
|
||||
import com.memory.app.ui.components.FullScreenImageViewer
|
||||
import com.memory.app.ui.components.MediaGrid
|
||||
import com.memory.app.ui.components.MusicCard
|
||||
@@ -416,8 +417,8 @@ fun PostDetailScreen(
|
||||
// Image Viewer
|
||||
showImageViewerIndex?.let { initialIndex ->
|
||||
post?.let { p ->
|
||||
FullScreenImageViewer(
|
||||
imageUrls = p.media.map { it.mediaUrl },
|
||||
FullScreenMediaViewer(
|
||||
media = p.media,
|
||||
initialIndex = initialIndex,
|
||||
onDismiss = { showImageViewerIndex = null }
|
||||
)
|
||||
|
||||
@@ -36,6 +36,9 @@ class HomeViewModel : ViewModel() {
|
||||
private val _editSuccess = MutableStateFlow(false)
|
||||
val editSuccess: StateFlow<Boolean> = _editSuccess
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage
|
||||
|
||||
private val _currentUser = MutableStateFlow<User?>(null)
|
||||
val currentUser: StateFlow<User?> = _currentUser
|
||||
|
||||
@@ -167,7 +170,30 @@ class HomeViewModel : ViewModel() {
|
||||
_isPosting.value = true
|
||||
_uploadProgress.value = emptyMap()
|
||||
try {
|
||||
// 并发上传新图片(带进度)
|
||||
// 验证视频数量和大小
|
||||
val existingVideoCount = existingUrls.count { it.endsWith(".mp4", ignoreCase = true) || it.endsWith(".mov", ignoreCase = true) }
|
||||
val newVideoUris = newImages.filter { isVideo(context, it) }
|
||||
val totalVideoCount = existingVideoCount + newVideoUris.size
|
||||
|
||||
if (totalVideoCount > 2) {
|
||||
_errorMessage.value = "最多只能上传2个视频"
|
||||
_isPosting.value = false
|
||||
_uploadProgress.value = emptyMap()
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 检查新视频大小
|
||||
for (uri in newVideoUris) {
|
||||
val size = getFileSize(context, uri)
|
||||
if (size > 100 * 1024 * 1024) { // 100MB
|
||||
_errorMessage.value = "视频大小不能超过100MB"
|
||||
_isPosting.value = false
|
||||
_uploadProgress.value = emptyMap()
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// 并发上传新图片/视频(带进度)
|
||||
val uploadJobs = newImages.mapIndexed { index, uri ->
|
||||
async {
|
||||
try {
|
||||
@@ -178,8 +204,20 @@ class HomeViewModel : ViewModel() {
|
||||
val bytes = inputStream?.readBytes() ?: return@async null
|
||||
inputStream.close()
|
||||
|
||||
val fileName = "image_${System.currentTimeMillis()}_$index.jpg"
|
||||
val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull())
|
||||
// 根据文件类型设置文件名和 MIME 类型
|
||||
val mimeType = context.contentResolver.getType(uri) ?: "image/*"
|
||||
val extension = when {
|
||||
mimeType.startsWith("video/") -> {
|
||||
when {
|
||||
mimeType.contains("mp4") -> ".mp4"
|
||||
mimeType.contains("quicktime") -> ".mov"
|
||||
else -> ".mp4"
|
||||
}
|
||||
}
|
||||
else -> ".jpg"
|
||||
}
|
||||
val fileName = "media_${System.currentTimeMillis()}_$index$extension"
|
||||
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
|
||||
|
||||
// 包装成带进度的 RequestBody
|
||||
val progressBody = ProgressRequestBody(requestBody) { progress ->
|
||||
@@ -243,7 +281,27 @@ class HomeViewModel : ViewModel() {
|
||||
_uploadProgress.value = emptyMap()
|
||||
|
||||
try {
|
||||
// 并发上传图片
|
||||
// 验证视频数量和大小
|
||||
val videoUris = images.filter { isVideo(context, it) }
|
||||
if (videoUris.size > 2) {
|
||||
_errorMessage.value = "最多只能上传2个视频"
|
||||
_isPosting.value = false
|
||||
_uploadProgress.value = emptyMap()
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 检查视频大小
|
||||
for (uri in videoUris) {
|
||||
val size = getFileSize(context, uri)
|
||||
if (size > 100 * 1024 * 1024) { // 100MB
|
||||
_errorMessage.value = "视频大小不能超过100MB"
|
||||
_isPosting.value = false
|
||||
_uploadProgress.value = emptyMap()
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// 并发上传图片/视频
|
||||
val uploadJobs = images.mapIndexed { index, uri ->
|
||||
async {
|
||||
try {
|
||||
@@ -254,8 +312,20 @@ class HomeViewModel : ViewModel() {
|
||||
val bytes = inputStream?.readBytes() ?: return@async null
|
||||
inputStream.close()
|
||||
|
||||
val fileName = "image_${System.currentTimeMillis()}_$index.jpg"
|
||||
val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull())
|
||||
// 根据文件类型设置文件名和 MIME 类型
|
||||
val mimeType = context.contentResolver.getType(uri) ?: "image/*"
|
||||
val extension = when {
|
||||
mimeType.startsWith("video/") -> {
|
||||
when {
|
||||
mimeType.contains("mp4") -> ".mp4"
|
||||
mimeType.contains("quicktime") -> ".mov"
|
||||
else -> ".mp4"
|
||||
}
|
||||
}
|
||||
else -> ".jpg"
|
||||
}
|
||||
val fileName = "media_${System.currentTimeMillis()}_$index$extension"
|
||||
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
|
||||
|
||||
// 包装成带进度的 RequestBody
|
||||
val progressBody = ProgressRequestBody(requestBody) { progress ->
|
||||
@@ -303,6 +373,24 @@ class HomeViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isVideo(context: Context, uri: Uri): Boolean {
|
||||
val mimeType = context.contentResolver.getType(uri) ?: return false
|
||||
return mimeType.startsWith("video/")
|
||||
}
|
||||
|
||||
private fun getFileSize(context: Context, uri: Uri): Long {
|
||||
return try {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
|
||||
cursor.moveToFirst()
|
||||
cursor.getLong(sizeIndex)
|
||||
} ?: 0L
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
fun resetPostSuccess() {
|
||||
_postSuccess.value = false
|
||||
}
|
||||
@@ -310,4 +398,8 @@ class HomeViewModel : ViewModel() {
|
||||
fun resetEditSuccess() {
|
||||
_editSuccess.value = false
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
}
|
||||
|
||||
6
android/app/src/main/res/values/strings.xml
Normal file
6
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Memory</string>
|
||||
<string name="exo_controls_play_description">播放</string>
|
||||
<string name="exo_controls_pause_description">暂停</string>
|
||||
</resources>
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"memory/internal/config"
|
||||
"memory/internal/middleware"
|
||||
@@ -30,6 +31,18 @@ func (h *PostHandler) Create(c *gin.Context) {
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
// 验证视频数量(最多2个)
|
||||
videoCount := 0
|
||||
for _, mediaURL := range req.MediaIDs {
|
||||
if isVideoURL(mediaURL) {
|
||||
videoCount++
|
||||
}
|
||||
}
|
||||
if videoCount > 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "最多只能上传2个视频"})
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := h.db.Begin()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||||
@@ -47,9 +60,13 @@ func (h *PostHandler) Create(c *gin.Context) {
|
||||
|
||||
// 关联媒体文件
|
||||
for i, mediaURL := range req.MediaIDs {
|
||||
mediaType := "image"
|
||||
if isVideoURL(mediaURL) {
|
||||
mediaType = "video"
|
||||
}
|
||||
_, err := tx.Exec(
|
||||
"INSERT INTO post_media (post_id, media_url, media_type, sort_order) VALUES (?, ?, 'image', ?)",
|
||||
postID, mediaURL, i,
|
||||
"INSERT INTO post_media (post_id, media_url, media_type, sort_order) VALUES (?, ?, ?, ?)",
|
||||
postID, mediaURL, mediaType, i,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to attach media"})
|
||||
@@ -217,6 +234,20 @@ func (h *PostHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证视频数量(最多2个)
|
||||
if req.MediaIDs != nil {
|
||||
videoCount := 0
|
||||
for _, mediaURL := range req.MediaIDs {
|
||||
if isVideoURL(mediaURL) {
|
||||
videoCount++
|
||||
}
|
||||
}
|
||||
if videoCount > 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "最多只能上传2个视频"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
var postUserID int64
|
||||
err := h.db.QueryRow("SELECT user_id FROM posts WHERE id = ?", postID).Scan(&postUserID)
|
||||
@@ -260,9 +291,13 @@ func (h *PostHandler) Update(c *gin.Context) {
|
||||
|
||||
// 添加新的媒体
|
||||
for i, mediaURL := range req.MediaIDs {
|
||||
mediaType := "image"
|
||||
if isVideoURL(mediaURL) {
|
||||
mediaType = "video"
|
||||
}
|
||||
_, err := tx.Exec(
|
||||
"INSERT INTO post_media (post_id, media_url, media_type, sort_order) VALUES (?, ?, 'image', ?)",
|
||||
postID, mediaURL, i,
|
||||
"INSERT INTO post_media (post_id, media_url, media_type, sort_order) VALUES (?, ?, ?, ?)",
|
||||
postID, mediaURL, mediaType, i,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to attach media"})
|
||||
@@ -468,3 +503,9 @@ func (h *PostHandler) getPostMusic(postID int64) *model.MusicInfo {
|
||||
}
|
||||
return &music
|
||||
}
|
||||
|
||||
// isVideoURL 判断URL是否为视频文件
|
||||
func isVideoURL(url string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(url), ".mp4") ||
|
||||
strings.HasSuffix(strings.ToLower(url), ".mov")
|
||||
}
|
||||
|
||||
@@ -78,9 +78,17 @@ func (h *UploadHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件大小 (最大 50MB)
|
||||
if file.Size > 50*1024*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 50MB)"})
|
||||
// 检查文件大小 (图片最大 50MB, 视频最大 100MB)
|
||||
maxSize := int64(50 * 1024 * 1024) // 默认 50MB
|
||||
if contentType == "video/mp4" || contentType == "video/quicktime" {
|
||||
maxSize = 100 * 1024 * 1024 // 视频 100MB
|
||||
}
|
||||
if file.Size > maxSize {
|
||||
if contentType == "video/mp4" || contentType == "video/quicktime" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "video too large (max 100MB)"})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 50MB)"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user