feat:增加上传视频&视频播放等功能
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 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) {
|
||||||
// 用户变化时清空旧数据并重新加载
|
// 用户变化时清空旧数据并重新加载
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
"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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user