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"
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")

View File

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

View File

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

View File

@@ -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,19 +1051,27 @@ fun FullScreenImageViewer(
.fillMaxSize()
.background(Color.Black)
) {
// 图片分页器
// 媒体分页器
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
userScrollEnabled = !isCurrentPageZoomed,
key = { it }
) { page ->
val item = media[page]
if (item.mediaType == "video") {
VideoPlayer(
videoUrl = item.mediaUrl,
modifier = Modifier.fillMaxSize()
)
} else {
ZoomableImage(
imageUrl = imageUrls[page],
imageUrl = item.mediaUrl,
onTap = onDismiss,
onScaleChanged = { scale -> zoomStates[page] = scale }
)
}
}
// 关闭按钮
IconButton(
@@ -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,

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 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) {
// 用户变化时清空旧数据并重新加载

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.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,15 +66,18 @@ 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
.fillMaxSize()
@@ -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(

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.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)
@@ -76,6 +78,13 @@ fun EditPostScreen(
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
.fillMaxSize()
@@ -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(

View File

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

View File

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

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

View File

@@ -78,9 +78,17 @@ func (h *UploadHandler) Upload(c *gin.Context) {
return
}
// 检查文件大小 (最大 50MB)
if file.Size > 50*1024*1024 {
// 检查文件大小 (图片最大 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
}