feat:增加上传视频&视频播放等功能

This commit is contained in:
amos
2026-02-28 17:12:14 +08:00
parent 2f3b206ad2
commit c696c3f8f6
13 changed files with 548 additions and 86 deletions

View File

@@ -13,11 +13,11 @@ android {
applicationId = "com.memory.app" applicationId = "com.memory.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 44 versionCode = 53
versionName = "1.6.0" versionName = "1.6.9"
buildConfigField("String", "API_BASE_URL", "\"https://x.amos.us.kg/api/\"") buildConfigField("String", "API_BASE_URL", "\"https://x.amos.us.kg/api/\"")
buildConfigField("int", "VERSION_CODE", "44") buildConfigField("int", "VERSION_CODE", "53")
} }
signingConfigs { signingConfigs {
@@ -82,6 +82,11 @@ dependencies {
// Image loading // Image loading
implementation("io.coil-kt:coil-compose:2.7.0") 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 // DataStore
implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("androidx.datastore:datastore-preferences:1.1.1")

View File

@@ -44,8 +44,11 @@ class MainActivity : ComponentActivity() {
credentialsManager = CredentialsManager(this) credentialsManager = CredentialsManager(this)
updateManager = UpdateManager(this) updateManager = UpdateManager(this)
// 配置 Coil 图片加载器 // 配置 Coil 图片加载器(支持视频帧提取)
val imageLoader = ImageLoader.Builder(this) val imageLoader = ImageLoader.Builder(this)
.components {
add(coil.decode.VideoFrameDecoder.Factory())
}
.okHttpClient { .okHttpClient {
OkHttpClient.Builder() OkHttpClient.Builder()
.build() .build()

View File

@@ -3,11 +3,17 @@ package com.memory.app.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -23,16 +29,14 @@ fun MediaGrid(
when (media.size) { when (media.size) {
1 -> { 1 -> {
AsyncImage( MediaItem(
model = media[0].mediaUrl, media = media[0],
contentDescription = null,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(16f / 10f) .aspectRatio(16f / 10f)
.clip(shape) .clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(0) }, .clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(0) }
contentScale = ContentScale.Crop
) )
} }
2 -> { 2 -> {
@@ -44,15 +48,13 @@ fun MediaGrid(
horizontalArrangement = Arrangement.spacedBy(2.dp) horizontalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
media.forEachIndexed { index, item -> media.forEachIndexed { index, item ->
AsyncImage( MediaItem(
model = item.mediaUrl, media = item,
contentDescription = null,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) }, .clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) }
contentScale = ContentScale.Crop
) )
} }
} }
@@ -65,30 +67,26 @@ fun MediaGrid(
.clip(shape), .clip(shape),
horizontalArrangement = Arrangement.spacedBy(2.dp) horizontalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
AsyncImage( MediaItem(
model = media[0].mediaUrl, media = media[0],
contentDescription = null,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(0) }, .clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(0) }
contentScale = ContentScale.Crop
) )
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
for (i in 1..2) { for (i in 1..2) {
AsyncImage( MediaItem(
model = media[i].mediaUrl, media = media[i],
contentDescription = null,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(i) }, .clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(i) }
contentScale = ContentScale.Crop
) )
} }
} }
@@ -115,15 +113,13 @@ fun MediaGrid(
for (col in 0 until columns) { for (col in 0 until columns) {
val index = row * columns + col val index = row * columns + col
if (index < media.size) { if (index < media.size) {
AsyncImage( MediaItem(
model = media[index].mediaUrl, media = media[index],
contentDescription = null,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.aspectRatio(1f) .aspectRatio(1f)
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) }, .clickable(enabled = onMediaClick != null) { onMediaClick?.invoke(index) }
contentScale = ContentScale.Crop
) )
} else { } 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
)
}
}
}
}
}

View File

@@ -291,10 +291,10 @@ fun PostCard(
) )
} }
// Image Viewer // Image/Video Viewer
showImageViewerIndex?.let { initialIndex -> showImageViewerIndex?.let { initialIndex ->
FullScreenImageViewer( FullScreenMediaViewer(
imageUrls = post.media.map { it.mediaUrl }, media = post.media,
initialIndex = initialIndex, initialIndex = initialIndex,
onDismiss = { showImageViewerIndex = null } onDismiss = { showImageViewerIndex = null }
) )
@@ -1026,14 +1026,14 @@ fun EditPostDialog(
} }
@Composable @Composable
fun FullScreenImageViewer( fun FullScreenMediaViewer(
imageUrls: List<String>, media: List<com.memory.app.data.model.Media>,
initialIndex: Int = 0, initialIndex: Int = 0,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
initialPage = initialIndex, initialPage = initialIndex,
pageCount = { imageUrls.size } pageCount = { media.size }
) )
// 跟踪当前页面是否处于缩放状态 // 跟踪当前页面是否处于缩放状态
@@ -1051,18 +1051,26 @@ fun FullScreenImageViewer(
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
) { ) {
// 图片分页器 // 媒体分页器
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
userScrollEnabled = !isCurrentPageZoomed, userScrollEnabled = !isCurrentPageZoomed,
key = { it } key = { it }
) { page -> ) { page ->
ZoomableImage( val item = media[page]
imageUrl = imageUrls[page], if (item.mediaType == "video") {
onTap = onDismiss, VideoPlayer(
onScaleChanged = { scale -> zoomStates[page] = scale } 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( Row(
modifier = Modifier modifier = Modifier
@@ -1090,7 +1098,7 @@ fun FullScreenImageViewer(
.padding(bottom = 32.dp), .padding(bottom = 32.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
repeat(imageUrls.size) { index -> repeat(media.size) { index ->
val isSelected = pagerState.currentPage == index val isSelected = pagerState.currentPage == index
val color by animateColorAsState( val color by animateColorAsState(
targetValue = if (isSelected) Color.White else Color.White.copy(alpha = 0.4f), 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 @Composable
private fun ZoomableImage( private fun ZoomableImage(
imageUrl: String, imageUrl: String,

View File

@@ -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)
}
}
}
}

View File

@@ -72,6 +72,15 @@ fun MainNavigation(
val notifications by notificationViewModel.notifications.collectAsState() val notifications by notificationViewModel.notifications.collectAsState()
val notificationLoading by notificationViewModel.isLoading.collectAsState() val notificationLoading by notificationViewModel.isLoading.collectAsState()
val uploadProgress by homeViewModel.uploadProgress.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) { LaunchedEffect(user) {
// 用户变化时清空旧数据并重新加载 // 用户变化时清空旧数据并重新加载

View File

@@ -22,6 +22,7 @@ import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.MusicNote import androidx.compose.material.icons.outlined.MusicNote
import androidx.compose.material.icons.outlined.Mood import androidx.compose.material.icons.outlined.Mood
import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Videocam
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -56,6 +57,7 @@ fun CreatePostScreen(
var content by remember { mutableStateOf("") } var content by remember { mutableStateOf("") }
var selectedImages by remember { mutableStateOf<List<Uri>>(emptyList()) } var selectedImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
var selectedVideos by remember { mutableStateOf<List<Uri>>(emptyList()) }
var showEmojiPicker by remember { mutableStateOf(false) } var showEmojiPicker by remember { mutableStateOf(false) }
var visibility by remember { mutableStateOf(0) } // 0=所有人可见, 1=仅自己可见 var visibility by remember { mutableStateOf(0) } // 0=所有人可见, 1=仅自己可见
var musicUrl by remember { mutableStateOf("") } var musicUrl by remember { mutableStateOf("") }
@@ -64,14 +66,17 @@ fun CreatePostScreen(
var showMusicInput by remember { mutableStateOf(false) } var showMusicInput by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// 计算还能选多少张
val maxSelectable = (9 - selectedImages.size).coerceAtLeast(0)
val imagePicker = rememberLauncherForActivityResult( val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9) contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9)
) { uris -> ) { uris ->
selectedImages = (selectedImages + uris).take(9) selectedImages = (selectedImages + uris).take(9)
} }
val videoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9)
) { uris ->
selectedVideos = (selectedVideos + uris).take(9)
}
Column( Column(
modifier = Modifier modifier = Modifier
@@ -103,7 +108,7 @@ fun CreatePostScreen(
// Publish button // Publish button
Button( Button(
onClick = { onPost(content, selectedImages, visibility, musicUrl.ifBlank { null }) }, onClick = { onPost(content, selectedImages + selectedVideos, visibility, musicUrl.ifBlank { null }) },
enabled = content.isNotBlank() && !isLoading, enabled = content.isNotBlank() && !isLoading,
shape = RoundedCornerShape(50), shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
@@ -198,22 +203,28 @@ fun CreatePostScreen(
) )
// Selected Images - 九宫格布局 // 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)) Spacer(modifier = Modifier.height(16.dp))
val columns = if (selectedImages.size <= 3) selectedImages.size else 3 val columns = if (allMedia.size <= 3) allMedia.size else 3
val rows = (selectedImages.size + columns - 1) / columns val rows = (allMedia.size + columns - 1) / columns
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (row in 0 until rows) { for (row in 0 until rows) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (col in 0 until columns) { for (col in 0 until columns) {
val index = row * columns + col val index = row * columns + col
if (index < selectedImages.size) { if (index < allMedia.size) {
val uri = selectedImages[index] val item = allMedia[index]
val progress = uploadProgress[index] val progress = uploadProgress[index]
SelectedImageItem( SelectedMediaItem(
uri = uri, item = item,
onRemove = if (!isLoading) {{ selectedImages = selectedImages - uri }} else null, onRemove = if (!isLoading) {{
when (item) {
is MediaItemType.Image -> selectedImages = selectedImages - item.uri
is MediaItemType.Video -> selectedVideos = selectedVideos - item.uri
}
}} else null,
uploadProgress = progress uploadProgress = progress
) )
} }
@@ -303,13 +314,31 @@ fun CreatePostScreen(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
) )
}, },
enabled = selectedImages.size < 9, enabled = (selectedImages.size + selectedVideos.size) < 9,
modifier = Modifier.size(40.dp) modifier = Modifier.size(40.dp)
) { ) {
Icon( Icon(
Icons.Outlined.Image, Icons.Outlined.Image,
contentDescription = "添加图片", 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) 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 @Composable
private fun SelectedImageItem( private fun SelectedMediaItem(
uri: Uri, item: MediaItemType,
onRemove: (() -> Unit)?, onRemove: (() -> Unit)?,
uploadProgress: Float? = null uploadProgress: Float? = null
) { ) {
Box { Box {
AsyncImage( AsyncImage(
model = uri, model = when (item) {
is MediaItemType.Image -> item.uri
is MediaItemType.Video -> item.uri
},
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(90.dp) .size(90.dp)
@@ -454,6 +492,35 @@ private fun SelectedImageItem(
contentScale = ContentScale.Crop 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) { if (uploadProgress != null && uploadProgress < 1f) {
Box( Box(

View File

@@ -22,6 +22,7 @@ import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Mood import androidx.compose.material.icons.outlined.Mood
import androidx.compose.material.icons.outlined.MusicNote import androidx.compose.material.icons.outlined.MusicNote
import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Videocam
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -55,8 +56,9 @@ fun EditPostScreen(
} }
var content by remember { mutableStateOf(post.content) } 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 newImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
var newVideos by remember { mutableStateOf<List<Uri>>(emptyList()) }
var showEmojiPicker by remember { mutableStateOf(false) } var showEmojiPicker by remember { mutableStateOf(false) }
var visibility by remember { mutableStateOf(post.visibility) } var visibility by remember { mutableStateOf(post.visibility) }
var musicUrl by remember { mutableStateOf(post.music?.shareUrl ?: "") } var musicUrl by remember { mutableStateOf(post.music?.shareUrl ?: "") }
@@ -67,7 +69,7 @@ fun EditPostScreen(
// 标记是否有原始音乐(用于判断是删除还是新增) // 标记是否有原始音乐(用于判断是删除还是新增)
val hadOriginalMusic = post.music != null val hadOriginalMusic = post.music != null
val totalImages = existingImages.size + newImages.size val totalImages = existingImages.size + newImages.size + newVideos.size
val imagePicker = rememberLauncherForActivityResult( val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9) contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9)
@@ -75,6 +77,13 @@ fun EditPostScreen(
val remaining = 9 - totalImages val remaining = 9 - totalImages
newImages = (newImages + uris).take(remaining.coerceAtLeast(0)) 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( Column(
modifier = Modifier modifier = Modifier
@@ -114,7 +123,7 @@ fun EditPostScreen(
hadOriginalMusic && musicUrl != post.music?.shareUrl -> musicUrl // 修改音乐(虽然目前不太可能) hadOriginalMusic && musicUrl != post.music?.shareUrl -> musicUrl // 修改音乐(虽然目前不太可能)
else -> null // 不修改 else -> null // 不修改
} }
onSave(content, existingImages, newImages, visibility, musicParam) onSave(content, existingImages.map { it.first }, newImages + newVideos, visibility, musicParam)
}, },
enabled = content.isNotBlank() && !isLoading, enabled = content.isNotBlank() && !isLoading,
shape = RoundedCornerShape(50), shape = RoundedCornerShape(50),
@@ -194,7 +203,9 @@ fun EditPostScreen(
) )
// Images - 合并已有图片和新图片,九宫格布局 // 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()) { if (allImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
val columns = if (allImages.size <= 3) allImages.size else 3 val columns = if (allImages.size <= 3) allImages.size else 3
@@ -208,15 +219,23 @@ fun EditPostScreen(
if (index < allImages.size) { if (index < allImages.size) {
val item = allImages[index] val item = allImages[index]
val progress = when (item) { 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 else -> null
} }
EditImageItem( EditImageItem(
item = item, item = item,
onRemove = if (!isLoading) {{ onRemove = if (!isLoading) {{
when (item) { when (item) {
is ImageItem.Existing -> existingImages = existingImages - item.url is ImageItem.Existing -> existingImages = existingImages.filter { it.first != item.url }
is ImageItem.New -> newImages = newImages - item.uri is ImageItem.NewImage -> newImages = newImages - item.uri
is ImageItem.NewVideo -> newVideos = newVideos - item.uri
} }
}} else null, }} else null,
uploadProgress = progress 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 // Music Button
IconButton( IconButton(
onClick = { showMusicInput = true }, onClick = { showMusicInput = true },
@@ -418,8 +455,9 @@ fun EditPostScreen(
// 图片项类型 // 图片项类型
private sealed class ImageItem { private sealed class ImageItem {
data class Existing(val url: String) : ImageItem() data class Existing(val url: String, val isVideo: Boolean) : ImageItem()
data class New(val uri: Uri, val index: Int) : ImageItem() data class NewImage(val uri: Uri) : ImageItem()
data class NewVideo(val uri: Uri) : ImageItem()
} }
@Composable @Composable
@@ -432,7 +470,8 @@ private fun EditImageItem(
AsyncImage( AsyncImage(
model = when (item) { model = when (item) {
is ImageItem.Existing -> item.url is ImageItem.Existing -> item.url
is ImageItem.New -> item.uri is ImageItem.NewImage -> item.uri
is ImageItem.NewVideo -> item.uri
}, },
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
@@ -441,6 +480,40 @@ private fun EditImageItem(
contentScale = ContentScale.Crop 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) { if (uploadProgress != null && uploadProgress < 1f) {
Box( Box(

View File

@@ -27,6 +27,7 @@ import coil.compose.AsyncImage
import com.memory.app.data.model.Comment import com.memory.app.data.model.Comment
import com.memory.app.data.model.Post import com.memory.app.data.model.Post
import com.memory.app.ui.components.EmojiPickerDialog 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.FullScreenImageViewer
import com.memory.app.ui.components.MediaGrid import com.memory.app.ui.components.MediaGrid
import com.memory.app.ui.components.MusicCard import com.memory.app.ui.components.MusicCard
@@ -416,8 +417,8 @@ fun PostDetailScreen(
// Image Viewer // Image Viewer
showImageViewerIndex?.let { initialIndex -> showImageViewerIndex?.let { initialIndex ->
post?.let { p -> post?.let { p ->
FullScreenImageViewer( FullScreenMediaViewer(
imageUrls = p.media.map { it.mediaUrl }, media = p.media,
initialIndex = initialIndex, initialIndex = initialIndex,
onDismiss = { showImageViewerIndex = null } onDismiss = { showImageViewerIndex = null }
) )

View File

@@ -36,6 +36,9 @@ class HomeViewModel : ViewModel() {
private val _editSuccess = MutableStateFlow(false) private val _editSuccess = MutableStateFlow(false)
val editSuccess: StateFlow<Boolean> = _editSuccess val editSuccess: StateFlow<Boolean> = _editSuccess
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage
private val _currentUser = MutableStateFlow<User?>(null) private val _currentUser = MutableStateFlow<User?>(null)
val currentUser: StateFlow<User?> = _currentUser val currentUser: StateFlow<User?> = _currentUser
@@ -167,7 +170,30 @@ class HomeViewModel : ViewModel() {
_isPosting.value = true _isPosting.value = true
_uploadProgress.value = emptyMap() _uploadProgress.value = emptyMap()
try { 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 -> val uploadJobs = newImages.mapIndexed { index, uri ->
async { async {
try { try {
@@ -178,8 +204,20 @@ class HomeViewModel : ViewModel() {
val bytes = inputStream?.readBytes() ?: return@async null val bytes = inputStream?.readBytes() ?: return@async null
inputStream.close() inputStream.close()
val fileName = "image_${System.currentTimeMillis()}_$index.jpg" // 根据文件类型设置文件名和 MIME 类型
val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull()) 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 // 包装成带进度的 RequestBody
val progressBody = ProgressRequestBody(requestBody) { progress -> val progressBody = ProgressRequestBody(requestBody) { progress ->
@@ -243,7 +281,27 @@ class HomeViewModel : ViewModel() {
_uploadProgress.value = emptyMap() _uploadProgress.value = emptyMap()
try { 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 -> val uploadJobs = images.mapIndexed { index, uri ->
async { async {
try { try {
@@ -254,8 +312,20 @@ class HomeViewModel : ViewModel() {
val bytes = inputStream?.readBytes() ?: return@async null val bytes = inputStream?.readBytes() ?: return@async null
inputStream.close() inputStream.close()
val fileName = "image_${System.currentTimeMillis()}_$index.jpg" // 根据文件类型设置文件名和 MIME 类型
val requestBody = bytes.toRequestBody("image/*".toMediaTypeOrNull()) 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 // 包装成带进度的 RequestBody
val progressBody = ProgressRequestBody(requestBody) { progress -> 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() { fun resetPostSuccess() {
_postSuccess.value = false _postSuccess.value = false
} }
@@ -310,4 +398,8 @@ class HomeViewModel : ViewModel() {
fun resetEditSuccess() { fun resetEditSuccess() {
_editSuccess.value = false _editSuccess.value = false
} }
fun clearError() {
_errorMessage.value = null
}
} }

View 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>

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"memory/internal/config" "memory/internal/config"
"memory/internal/middleware" "memory/internal/middleware"
@@ -30,6 +31,18 @@ func (h *PostHandler) Create(c *gin.Context) {
userID := middleware.GetUserID(c) 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() tx, err := h.db.Begin()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) 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 { for i, mediaURL := range req.MediaIDs {
mediaType := "image"
if isVideoURL(mediaURL) {
mediaType = "video"
}
_, err := tx.Exec( _, err := tx.Exec(
"INSERT INTO post_media (post_id, media_url, media_type, sort_order) VALUES (?, ?, 'image', ?)", "INSERT INTO post_media (post_id, media_url, media_type, sort_order) VALUES (?, ?, ?, ?)",
postID, mediaURL, i, postID, mediaURL, mediaType, i,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to attach media"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to attach media"})
@@ -217,6 +234,20 @@ func (h *PostHandler) Update(c *gin.Context) {
return 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 var postUserID int64
err := h.db.QueryRow("SELECT user_id FROM posts WHERE id = ?", postID).Scan(&postUserID) 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 { for i, mediaURL := range req.MediaIDs {
mediaType := "image"
if isVideoURL(mediaURL) {
mediaType = "video"
}
_, err := tx.Exec( _, err := tx.Exec(
"INSERT INTO post_media (post_id, media_url, media_type, sort_order) VALUES (?, ?, 'image', ?)", "INSERT INTO post_media (post_id, media_url, media_type, sort_order) VALUES (?, ?, ?, ?)",
postID, mediaURL, i, postID, mediaURL, mediaType, i,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to attach media"}) 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 return &music
} }
// isVideoURL 判断URL是否为视频文件
func isVideoURL(url string) bool {
return strings.HasSuffix(strings.ToLower(url), ".mp4") ||
strings.HasSuffix(strings.ToLower(url), ".mov")
}

View File

@@ -78,9 +78,17 @@ func (h *UploadHandler) Upload(c *gin.Context) {
return return
} }
// 检查文件大小 (最大 50MB) // 检查文件大小 (图片最大 50MB, 视频最大 100MB)
if file.Size > 50*1024*1024 { maxSize := int64(50 * 1024 * 1024) // 默认 50MB
c.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 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 return
} }