diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 814de1d..4d477ce 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") diff --git a/android/app/src/main/java/com/memory/app/MainActivity.kt b/android/app/src/main/java/com/memory/app/MainActivity.kt index aac73eb..5019fd5 100644 --- a/android/app/src/main/java/com/memory/app/MainActivity.kt +++ b/android/app/src/main/java/com/memory/app/MainActivity.kt @@ -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() diff --git a/android/app/src/main/java/com/memory/app/ui/components/MediaGrid.kt b/android/app/src/main/java/com/memory/app/ui/components/MediaGrid.kt index 5149249..0e9e802 100644 --- a/android/app/src/main/java/com/memory/app/ui/components/MediaGrid.kt +++ b/android/app/src/main/java/com/memory/app/ui/components/MediaGrid.kt @@ -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 + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/components/PostCard.kt b/android/app/src/main/java/com/memory/app/ui/components/PostCard.kt index e7c49f3..c1df222 100644 --- a/android/app/src/main/java/com/memory/app/ui/components/PostCard.kt +++ b/android/app/src/main/java/com/memory/app/ui/components/PostCard.kt @@ -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, +fun FullScreenMediaViewer( + media: List, initialIndex: Int = 0, onDismiss: () -> Unit ) { val pagerState = rememberPagerState( initialPage = initialIndex, - pageCount = { imageUrls.size } + pageCount = { media.size } ) // 跟踪当前页面是否处于缩放状态 @@ -1051,18 +1051,26 @@ fun FullScreenImageViewer( .fillMaxSize() .background(Color.Black) ) { - // 图片分页器 + // 媒体分页器 HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), userScrollEnabled = !isCurrentPageZoomed, key = { it } ) { page -> - ZoomableImage( - imageUrl = imageUrls[page], - onTap = onDismiss, - onScaleChanged = { scale -> zoomStates[page] = scale } - ) + val item = media[page] + if (item.mediaType == "video") { + VideoPlayer( + videoUrl = item.mediaUrl, + modifier = Modifier.fillMaxSize() + ) + } else { + ZoomableImage( + imageUrl = item.mediaUrl, + onTap = onDismiss, + onScaleChanged = { scale -> zoomStates[page] = scale } + ) + } } // 关闭按钮 @@ -1081,8 +1089,8 @@ fun FullScreenImageViewer( ) } - // 页码指示器 (多张图片时显示) - if (imageUrls.size > 1) { + // 页码指示器 (多个媒体时显示) + if (media.size > 1) { // 底部圆点指示器 Row( modifier = Modifier @@ -1090,7 +1098,7 @@ fun FullScreenImageViewer( .padding(bottom = 32.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - repeat(imageUrls.size) { index -> + repeat(media.size) { index -> val isSelected = pagerState.currentPage == index val color by animateColorAsState( targetValue = if (isSelected) Color.White else Color.White.copy(alpha = 0.4f), @@ -1110,6 +1118,25 @@ fun FullScreenImageViewer( } } +// 保留旧的函数名以兼容 +@Composable +fun FullScreenImageViewer( + imageUrls: List, + 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, diff --git a/android/app/src/main/java/com/memory/app/ui/components/VideoPlayer.kt b/android/app/src/main/java/com/memory/app/ui/components/VideoPlayer.kt new file mode 100644 index 0000000..ab6fe64 --- /dev/null +++ b/android/app/src/main/java/com/memory/app/ui/components/VideoPlayer.kt @@ -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) + } + } + } +} diff --git a/android/app/src/main/java/com/memory/app/ui/navigation/Navigation.kt b/android/app/src/main/java/com/memory/app/ui/navigation/Navigation.kt index 41ae925..d1a415a 100644 --- a/android/app/src/main/java/com/memory/app/ui/navigation/Navigation.kt +++ b/android/app/src/main/java/com/memory/app/ui/navigation/Navigation.kt @@ -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) { // 用户变化时清空旧数据并重新加载 diff --git a/android/app/src/main/java/com/memory/app/ui/screen/CreatePostScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/CreatePostScreen.kt index 86e128b..abed9f4 100644 --- a/android/app/src/main/java/com/memory/app/ui/screen/CreatePostScreen.kt +++ b/android/app/src/main/java/com/memory/app/ui/screen/CreatePostScreen.kt @@ -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>(emptyList()) } + var selectedVideos by remember { mutableStateOf>(emptyList()) } var showEmojiPicker by remember { mutableStateOf(false) } var visibility by remember { mutableStateOf(0) } // 0=所有人可见, 1=仅自己可见 var musicUrl by remember { mutableStateOf("") } @@ -64,14 +66,17 @@ fun CreatePostScreen( var showMusicInput by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - // 计算还能选多少张 - val maxSelectable = (9 - selectedImages.size).coerceAtLeast(0) - val imagePicker = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9) ) { uris -> selectedImages = (selectedImages + uris).take(9) } + + val videoPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9) + ) { uris -> + selectedVideos = (selectedVideos + uris).take(9) + } Column( modifier = Modifier @@ -103,7 +108,7 @@ fun CreatePostScreen( // Publish button Button( - onClick = { onPost(content, selectedImages, visibility, musicUrl.ifBlank { null }) }, + onClick = { onPost(content, selectedImages + selectedVideos, visibility, musicUrl.ifBlank { null }) }, enabled = content.isNotBlank() && !isLoading, shape = RoundedCornerShape(50), colors = ButtonDefaults.buttonColors( @@ -198,22 +203,28 @@ fun CreatePostScreen( ) // Selected Images - 九宫格布局 - if (selectedImages.isNotEmpty()) { + val allMedia = selectedImages.map { MediaItemType.Image(it) } + selectedVideos.map { MediaItemType.Video(it) } + if (allMedia.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) - val columns = if (selectedImages.size <= 3) selectedImages.size else 3 - val rows = (selectedImages.size + columns - 1) / columns + val columns = if (allMedia.size <= 3) allMedia.size else 3 + val rows = (allMedia.size + columns - 1) / columns Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { for (row in 0 until rows) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { for (col in 0 until columns) { val index = row * columns + col - if (index < selectedImages.size) { - val uri = selectedImages[index] + if (index < allMedia.size) { + val item = allMedia[index] val progress = uploadProgress[index] - SelectedImageItem( - uri = uri, - onRemove = if (!isLoading) {{ selectedImages = selectedImages - uri }} else null, + SelectedMediaItem( + item = item, + onRemove = if (!isLoading) {{ + when (item) { + is MediaItemType.Image -> selectedImages = selectedImages - item.uri + is MediaItemType.Video -> selectedVideos = selectedVideos - item.uri + } + }} else null, uploadProgress = progress ) } @@ -303,13 +314,31 @@ fun CreatePostScreen( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) ) }, - enabled = selectedImages.size < 9, + enabled = (selectedImages.size + selectedVideos.size) < 9, modifier = Modifier.size(40.dp) ) { Icon( Icons.Outlined.Image, contentDescription = "添加图片", - tint = if (selectedImages.size < 9) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant, + tint = if ((selectedImages.size + selectedVideos.size) < 9) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + + // Video Button + IconButton( + onClick = { + videoPicker.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly) + ) + }, + enabled = (selectedImages.size + selectedVideos.size) < 9, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Outlined.Videocam, + contentDescription = "添加视频", + tint = if ((selectedImages.size + selectedVideos.size) < 9) Brand500 else MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp) ) } @@ -438,15 +467,24 @@ fun CreatePostScreen( } } +// 媒体项类型 +private sealed class MediaItemType { + data class Image(val uri: Uri) : MediaItemType() + data class Video(val uri: Uri) : MediaItemType() +} + @Composable -private fun SelectedImageItem( - uri: Uri, +private fun SelectedMediaItem( + item: MediaItemType, onRemove: (() -> Unit)?, uploadProgress: Float? = null ) { Box { AsyncImage( - model = uri, + model = when (item) { + is MediaItemType.Image -> item.uri + is MediaItemType.Video -> item.uri + }, contentDescription = null, modifier = Modifier .size(90.dp) @@ -454,6 +492,35 @@ private fun SelectedImageItem( contentScale = ContentScale.Crop ) + // 视频标识 + if (item is MediaItemType.Video) { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(6.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.Black.copy(alpha = 0.7f)) + .padding(horizontal = 6.dp, vertical = 3.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon( + imageVector = Icons.Outlined.Videocam, + contentDescription = "视频", + modifier = Modifier.size(12.dp), + tint = Color.White + ) + androidx.compose.material3.Text( + text = "视频", + fontSize = 10.sp, + color = Color.White + ) + } + } + } + // 上传进度遮罩 if (uploadProgress != null && uploadProgress < 1f) { Box( diff --git a/android/app/src/main/java/com/memory/app/ui/screen/EditPostScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/EditPostScreen.kt index 33cdcae..6c6c03d 100644 --- a/android/app/src/main/java/com/memory/app/ui/screen/EditPostScreen.kt +++ b/android/app/src/main/java/com/memory/app/ui/screen/EditPostScreen.kt @@ -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>(emptyList()) } + var newVideos by remember { mutableStateOf>(emptyList()) } var showEmojiPicker by remember { mutableStateOf(false) } var visibility by remember { mutableStateOf(post.visibility) } var musicUrl by remember { mutableStateOf(post.music?.shareUrl ?: "") } @@ -67,7 +69,7 @@ fun EditPostScreen( // 标记是否有原始音乐(用于判断是删除还是新增) val hadOriginalMusic = post.music != null - val totalImages = existingImages.size + newImages.size + val totalImages = existingImages.size + newImages.size + newVideos.size val imagePicker = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9) @@ -75,6 +77,13 @@ fun EditPostScreen( val remaining = 9 - totalImages newImages = (newImages + uris).take(remaining.coerceAtLeast(0)) } + + val videoPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 9) + ) { uris -> + val remaining = 9 - totalImages + newVideos = (newVideos + uris).take(remaining.coerceAtLeast(0)) + } Column( modifier = Modifier @@ -114,7 +123,7 @@ fun EditPostScreen( hadOriginalMusic && musicUrl != post.music?.shareUrl -> musicUrl // 修改音乐(虽然目前不太可能) else -> null // 不修改 } - onSave(content, existingImages, newImages, visibility, musicParam) + onSave(content, existingImages.map { it.first }, newImages + newVideos, visibility, musicParam) }, enabled = content.isNotBlank() && !isLoading, shape = RoundedCornerShape(50), @@ -194,7 +203,9 @@ fun EditPostScreen( ) // Images - 合并已有图片和新图片,九宫格布局 - val allImages = existingImages.map { ImageItem.Existing(it) } + newImages.mapIndexed { idx, uri -> ImageItem.New(uri, idx) } + val allImages = existingImages.map { (url, isVideo) -> ImageItem.Existing(url, isVideo) } + + newImages.map { ImageItem.NewImage(it) } + + newVideos.map { ImageItem.NewVideo(it) } if (allImages.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) val columns = if (allImages.size <= 3) allImages.size else 3 @@ -208,15 +219,23 @@ fun EditPostScreen( if (index < allImages.size) { val item = allImages[index] val progress = when (item) { - is ImageItem.New -> uploadProgress[item.index] + is ImageItem.NewImage -> { + val newImageIndex = newImages.indexOf(item.uri) + if (newImageIndex >= 0) uploadProgress[existingImages.size + newImageIndex] else null + } + is ImageItem.NewVideo -> { + val newVideoIndex = newVideos.indexOf(item.uri) + if (newVideoIndex >= 0) uploadProgress[existingImages.size + newImages.size + newVideoIndex] else null + } else -> null } EditImageItem( item = item, onRemove = if (!isLoading) {{ when (item) { - is ImageItem.Existing -> existingImages = existingImages - item.url - is ImageItem.New -> newImages = newImages - item.uri + is ImageItem.Existing -> existingImages = existingImages.filter { it.first != item.url } + is ImageItem.NewImage -> newImages = newImages - item.uri + is ImageItem.NewVideo -> newVideos = newVideos - item.uri } }} else null, uploadProgress = progress @@ -314,6 +333,24 @@ fun EditPostScreen( ) } + // Video Button + IconButton( + onClick = { + videoPicker.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly) + ) + }, + enabled = totalImages < 9, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Outlined.Videocam, + contentDescription = "添加视频", + tint = if (totalImages < 9) Brand500 else Slate300, + modifier = Modifier.size(24.dp) + ) + } + // Music Button IconButton( onClick = { showMusicInput = true }, @@ -418,8 +455,9 @@ fun EditPostScreen( // 图片项类型 private sealed class ImageItem { - data class Existing(val url: String) : ImageItem() - data class New(val uri: Uri, val index: Int) : ImageItem() + data class Existing(val url: String, val isVideo: Boolean) : ImageItem() + data class NewImage(val uri: Uri) : ImageItem() + data class NewVideo(val uri: Uri) : ImageItem() } @Composable @@ -432,7 +470,8 @@ private fun EditImageItem( AsyncImage( model = when (item) { is ImageItem.Existing -> item.url - is ImageItem.New -> item.uri + is ImageItem.NewImage -> item.uri + is ImageItem.NewVideo -> item.uri }, contentDescription = null, modifier = Modifier @@ -441,6 +480,40 @@ private fun EditImageItem( contentScale = ContentScale.Crop ) + // 视频标识 + val isVideo = when (item) { + is ImageItem.Existing -> item.isVideo + is ImageItem.NewVideo -> true + else -> false + } + if (isVideo) { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(6.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.Black.copy(alpha = 0.7f)) + .padding(horizontal = 6.dp, vertical = 3.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon( + imageVector = Icons.Outlined.Videocam, + contentDescription = "视频", + modifier = Modifier.size(12.dp), + tint = Color.White + ) + Text( + text = "视频", + fontSize = 10.sp, + color = Color.White + ) + } + } + } + // 上传进度遮罩 if (uploadProgress != null && uploadProgress < 1f) { Box( diff --git a/android/app/src/main/java/com/memory/app/ui/screen/PostDetailScreen.kt b/android/app/src/main/java/com/memory/app/ui/screen/PostDetailScreen.kt index d37e66a..f564379 100644 --- a/android/app/src/main/java/com/memory/app/ui/screen/PostDetailScreen.kt +++ b/android/app/src/main/java/com/memory/app/ui/screen/PostDetailScreen.kt @@ -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 } ) diff --git a/android/app/src/main/java/com/memory/app/ui/viewmodel/HomeViewModel.kt b/android/app/src/main/java/com/memory/app/ui/viewmodel/HomeViewModel.kt index 712c3f9..6e3efce 100644 --- a/android/app/src/main/java/com/memory/app/ui/viewmodel/HomeViewModel.kt +++ b/android/app/src/main/java/com/memory/app/ui/viewmodel/HomeViewModel.kt @@ -36,6 +36,9 @@ class HomeViewModel : ViewModel() { private val _editSuccess = MutableStateFlow(false) val editSuccess: StateFlow = _editSuccess + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage + private val _currentUser = MutableStateFlow(null) val currentUser: StateFlow = _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 + } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a2cacf6 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Memory + 播放 + 暂停 + diff --git a/server/internal/handler/post.go b/server/internal/handler/post.go index f359104..530484c 100644 --- a/server/internal/handler/post.go +++ b/server/internal/handler/post.go @@ -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") +} diff --git a/server/internal/handler/upload.go b/server/internal/handler/upload.go index 87b70b8..078100f 100644 --- a/server/internal/handler/upload.go +++ b/server/internal/handler/upload.go @@ -78,9 +78,17 @@ func (h *UploadHandler) Upload(c *gin.Context) { return } - // 检查文件大小 (最大 50MB) - if file.Size > 50*1024*1024 { - c.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 50MB)"}) + // 检查文件大小 (图片最大 50MB, 视频最大 100MB) + maxSize := int64(50 * 1024 * 1024) // 默认 50MB + if contentType == "video/mp4" || contentType == "video/quicktime" { + maxSize = 100 * 1024 * 1024 // 视频 100MB + } + if file.Size > maxSize { + if contentType == "video/mp4" || contentType == "video/quicktime" { + c.JSON(http.StatusBadRequest, gin.H{"error": "video too large (max 100MB)"}) + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 50MB)"}) + } return }