feat: 新增短视频功能
- 新增短视频信息流页面,支持上下滑动切换视频。 - 实现视频播放、暂停、加载、空状态及错误处理等基础功能。 - 在视频页面中集成点赞、评论、收藏等互动操作。 - 后端接口新增 `videoFilter` 参数,用于仅获取包含视频的动态。 - 扩展了 `MomentEntity` 和相关数据模型,以支持视频数据。 - 将短视频页面集成到动态(Moment)Tab中。
This commit is contained in:
@@ -4,6 +4,7 @@ import com.aiosman.ravenow.R
|
|||||||
import com.aiosman.ravenow.data.api.ApiClient
|
import com.aiosman.ravenow.data.api.ApiClient
|
||||||
import com.aiosman.ravenow.entity.MomentEntity
|
import com.aiosman.ravenow.entity.MomentEntity
|
||||||
import com.aiosman.ravenow.entity.MomentImageEntity
|
import com.aiosman.ravenow.entity.MomentImageEntity
|
||||||
|
import com.aiosman.ravenow.entity.MomentVideoEntity
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -12,8 +13,12 @@ data class Moment(
|
|||||||
val id: Long,
|
val id: Long,
|
||||||
@SerializedName("textContent")
|
@SerializedName("textContent")
|
||||||
val textContent: String,
|
val textContent: String,
|
||||||
|
@SerializedName("url")
|
||||||
|
val url: String? = null,
|
||||||
@SerializedName("images")
|
@SerializedName("images")
|
||||||
val images: List<Image>,
|
val images: List<Image>? = null,
|
||||||
|
@SerializedName("videos")
|
||||||
|
val videos: List<Video>? = null,
|
||||||
@SerializedName("user")
|
@SerializedName("user")
|
||||||
val user: User,
|
val user: User,
|
||||||
@SerializedName("likeCount")
|
@SerializedName("likeCount")
|
||||||
@@ -24,7 +29,7 @@ data class Moment(
|
|||||||
val favoriteCount: Long,
|
val favoriteCount: Long,
|
||||||
@SerializedName("isFavorite")
|
@SerializedName("isFavorite")
|
||||||
val isFavorite: Boolean,
|
val isFavorite: Boolean,
|
||||||
@SerializedName("shareCount")
|
@SerializedName("isCommented")
|
||||||
val isCommented: Boolean,
|
val isCommented: Boolean,
|
||||||
@SerializedName("commentCount")
|
@SerializedName("commentCount")
|
||||||
val commentCount: Long,
|
val commentCount: Long,
|
||||||
@@ -47,6 +52,14 @@ data class Moment(
|
|||||||
val newsLanguage: String? = null,
|
val newsLanguage: String? = null,
|
||||||
@SerializedName("newsContent")
|
@SerializedName("newsContent")
|
||||||
val newsContent: String? = null,
|
val newsContent: String? = null,
|
||||||
|
@SerializedName("hasFullText")
|
||||||
|
val hasFullText: Boolean = false,
|
||||||
|
@SerializedName("summary")
|
||||||
|
val summary: String? = null,
|
||||||
|
@SerializedName("publishedAt")
|
||||||
|
val publishedAt: String? = null,
|
||||||
|
@SerializedName("imageCached")
|
||||||
|
val imageCached: Boolean = false
|
||||||
) {
|
) {
|
||||||
fun toMomentItem(): MomentEntity {
|
fun toMomentItem(): MomentEntity {
|
||||||
return MomentEntity(
|
return MomentEntity(
|
||||||
@@ -62,7 +75,7 @@ data class Moment(
|
|||||||
commentCount = commentCount.toInt(),
|
commentCount = commentCount.toInt(),
|
||||||
shareCount = 0,
|
shareCount = 0,
|
||||||
favoriteCount = favoriteCount.toInt(),
|
favoriteCount = favoriteCount.toInt(),
|
||||||
images = images.map {
|
images = images?.map {
|
||||||
MomentImageEntity(
|
MomentImageEntity(
|
||||||
url = "${ApiClient.BASE_SERVER}${it.url}",
|
url = "${ApiClient.BASE_SERVER}${it.url}",
|
||||||
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
|
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
|
||||||
@@ -71,10 +84,28 @@ data class Moment(
|
|||||||
width = it.width,
|
width = it.width,
|
||||||
height = it.height
|
height = it.height
|
||||||
)
|
)
|
||||||
},
|
} ?: emptyList(),
|
||||||
authorId = user.id.toInt(),
|
authorId = user.id.toInt(),
|
||||||
liked = isLiked,
|
liked = isLiked,
|
||||||
isFavorite = isFavorite,
|
isFavorite = isFavorite,
|
||||||
|
url = url,
|
||||||
|
videos = videos?.map {
|
||||||
|
MomentVideoEntity(
|
||||||
|
id = it.id,
|
||||||
|
url = "${ApiClient.BASE_SERVER}${it.url}",
|
||||||
|
originalUrl = it.originalUrl,
|
||||||
|
directUrl = it.directUrl,
|
||||||
|
thumbnailUrl = it.thumbnailUrl?.let { thumb -> "${ApiClient.BASE_SERVER}$thumb" },
|
||||||
|
thumbnailDirectUrl = it.thumbnailDirectUrl,
|
||||||
|
duration = it.duration,
|
||||||
|
width = it.width,
|
||||||
|
height = it.height,
|
||||||
|
size = it.size,
|
||||||
|
format = it.format,
|
||||||
|
bitrate = it.bitrate,
|
||||||
|
frameRate = it.frameRate
|
||||||
|
)
|
||||||
|
},
|
||||||
// 新闻相关字段
|
// 新闻相关字段
|
||||||
isNews = isNews,
|
isNews = isNews,
|
||||||
newsTitle = newsTitle ?: "",
|
newsTitle = newsTitle ?: "",
|
||||||
@@ -82,7 +113,11 @@ data class Moment(
|
|||||||
newsSource = newsSource ?: "",
|
newsSource = newsSource ?: "",
|
||||||
newsCategory = newsCategory ?: "",
|
newsCategory = newsCategory ?: "",
|
||||||
newsLanguage = newsLanguage ?: "",
|
newsLanguage = newsLanguage ?: "",
|
||||||
newsContent = newsContent ?: ""
|
newsContent = newsContent ?: "",
|
||||||
|
hasFullText = hasFullText,
|
||||||
|
summary = summary,
|
||||||
|
publishedAt = publishedAt,
|
||||||
|
imageCached = imageCached
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,8 +127,26 @@ data class Image(
|
|||||||
val id: Long,
|
val id: Long,
|
||||||
@SerializedName("url")
|
@SerializedName("url")
|
||||||
val url: String,
|
val url: String,
|
||||||
|
@SerializedName("original_url")
|
||||||
|
val originalUrl: String? = null,
|
||||||
|
@SerializedName("directUrl")
|
||||||
|
val directUrl: String? = null,
|
||||||
@SerializedName("thumbnail")
|
@SerializedName("thumbnail")
|
||||||
val thumbnail: String,
|
val thumbnail: String,
|
||||||
|
@SerializedName("thumbnailDirectUrl")
|
||||||
|
val thumbnailDirectUrl: String? = null,
|
||||||
|
@SerializedName("small")
|
||||||
|
val small: String? = null,
|
||||||
|
@SerializedName("smallDirectUrl")
|
||||||
|
val smallDirectUrl: String? = null,
|
||||||
|
@SerializedName("medium")
|
||||||
|
val medium: String? = null,
|
||||||
|
@SerializedName("mediumDirectUrl")
|
||||||
|
val mediumDirectUrl: String? = null,
|
||||||
|
@SerializedName("large")
|
||||||
|
val large: String? = null,
|
||||||
|
@SerializedName("largeDirectUrl")
|
||||||
|
val largeDirectUrl: String? = null,
|
||||||
@SerializedName("blurHash")
|
@SerializedName("blurHash")
|
||||||
val blurHash: String?,
|
val blurHash: String?,
|
||||||
@SerializedName("width")
|
@SerializedName("width")
|
||||||
@@ -102,13 +155,68 @@ data class Image(
|
|||||||
val height: Int?
|
val height: Int?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class Video(
|
||||||
|
@SerializedName("id")
|
||||||
|
val id: Long,
|
||||||
|
@SerializedName("url")
|
||||||
|
val url: String,
|
||||||
|
@SerializedName("original_url")
|
||||||
|
val originalUrl: String? = null,
|
||||||
|
@SerializedName("directUrl")
|
||||||
|
val directUrl: String? = null,
|
||||||
|
@SerializedName("thumbnailUrl")
|
||||||
|
val thumbnailUrl: String? = null,
|
||||||
|
@SerializedName("thumbnailDirectUrl")
|
||||||
|
val thumbnailDirectUrl: String? = null,
|
||||||
|
@SerializedName("duration")
|
||||||
|
val duration: Int? = null,
|
||||||
|
@SerializedName("width")
|
||||||
|
val width: Int? = null,
|
||||||
|
@SerializedName("height")
|
||||||
|
val height: Int? = null,
|
||||||
|
@SerializedName("size")
|
||||||
|
val size: Long? = null,
|
||||||
|
@SerializedName("format")
|
||||||
|
val format: String? = null,
|
||||||
|
@SerializedName("bitrate")
|
||||||
|
val bitrate: Int? = null,
|
||||||
|
@SerializedName("frameRate")
|
||||||
|
val frameRate: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
data class User(
|
data class User(
|
||||||
@SerializedName("id")
|
@SerializedName("id")
|
||||||
val id: Long,
|
val id: Long,
|
||||||
@SerializedName("nickName")
|
@SerializedName("nickName")
|
||||||
val nickName: String,
|
val nickName: String,
|
||||||
@SerializedName("avatar")
|
@SerializedName("avatar")
|
||||||
val avatar: String
|
val avatar: String,
|
||||||
|
@SerializedName("avatarMedium")
|
||||||
|
val avatarMedium: String? = null,
|
||||||
|
@SerializedName("avatarLarge")
|
||||||
|
val avatarLarge: String? = null,
|
||||||
|
@SerializedName("originAvatar")
|
||||||
|
val originAvatar: String? = null,
|
||||||
|
@SerializedName("avatarDirectUrl")
|
||||||
|
val avatarDirectUrl: String? = null,
|
||||||
|
@SerializedName("avatarMediumDirectUrl")
|
||||||
|
val avatarMediumDirectUrl: String? = null,
|
||||||
|
@SerializedName("avatarLargeDirectUrl")
|
||||||
|
val avatarLargeDirectUrl: String? = null,
|
||||||
|
@SerializedName("aiAccount")
|
||||||
|
val aiAccount: Boolean = false,
|
||||||
|
@SerializedName("aiRoleAvatar")
|
||||||
|
val aiRoleAvatar: String? = null,
|
||||||
|
@SerializedName("aiRoleAvatarMedium")
|
||||||
|
val aiRoleAvatarMedium: String? = null,
|
||||||
|
@SerializedName("aiRoleAvatarLarge")
|
||||||
|
val aiRoleAvatarLarge: String? = null,
|
||||||
|
@SerializedName("aiRoleAvatarDirectUrl")
|
||||||
|
val aiRoleAvatarDirectUrl: String? = null,
|
||||||
|
@SerializedName("aiRoleAvatarMediumDirectUrl")
|
||||||
|
val aiRoleAvatarMediumDirectUrl: String? = null,
|
||||||
|
@SerializedName("aiRoleAvatarLargeDirectUrl")
|
||||||
|
val aiRoleAvatarLargeDirectUrl: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class UploadImage(
|
data class UploadImage(
|
||||||
|
|||||||
@@ -656,6 +656,7 @@ interface RaveNowAPI {
|
|||||||
@Query("favouriteUserId") favouriteUserId: Int? = null,
|
@Query("favouriteUserId") favouriteUserId: Int? = null,
|
||||||
@Query("explore") explore: String? = null,
|
@Query("explore") explore: String? = null,
|
||||||
@Query("newsFilter") newsFilter: String? = null,
|
@Query("newsFilter") newsFilter: String? = null,
|
||||||
|
@Query("videoFilter") videoFilter: String? = null,
|
||||||
): Response<ListContainer<Moment>>
|
): Response<ListContainer<Moment>>
|
||||||
|
|
||||||
@Multipart
|
@Multipart
|
||||||
|
|||||||
@@ -260,6 +260,38 @@ data class MomentImageEntity(
|
|||||||
var height: Int? = null
|
var height: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态视频
|
||||||
|
*/
|
||||||
|
data class MomentVideoEntity(
|
||||||
|
// 视频ID
|
||||||
|
val id: Long,
|
||||||
|
// 视频URL
|
||||||
|
val url: String,
|
||||||
|
// 原始文件名
|
||||||
|
val originalUrl: String? = null,
|
||||||
|
// 直接访问URL
|
||||||
|
val directUrl: String? = null,
|
||||||
|
// 视频缩略图URL
|
||||||
|
val thumbnailUrl: String? = null,
|
||||||
|
// 视频缩略图直接访问URL
|
||||||
|
val thumbnailDirectUrl: String? = null,
|
||||||
|
// 视频时长(秒)
|
||||||
|
val duration: Int? = null,
|
||||||
|
// 宽度
|
||||||
|
val width: Int? = null,
|
||||||
|
// 高度
|
||||||
|
val height: Int? = null,
|
||||||
|
// 文件大小(字节)
|
||||||
|
val size: Long? = null,
|
||||||
|
// 视频格式
|
||||||
|
val format: String? = null,
|
||||||
|
// 视频比特率(kbps)
|
||||||
|
val bitrate: Int? = null,
|
||||||
|
// 帧率
|
||||||
|
val frameRate: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 动态
|
* 动态
|
||||||
*/
|
*/
|
||||||
@@ -300,6 +332,10 @@ data class MomentEntity(
|
|||||||
var relMoment: MomentEntity? = null,
|
var relMoment: MomentEntity? = null,
|
||||||
// 是否收藏
|
// 是否收藏
|
||||||
var isFavorite: Boolean = false,
|
var isFavorite: Boolean = false,
|
||||||
|
// 外部链接
|
||||||
|
val url: String? = null,
|
||||||
|
// 动态视频列表
|
||||||
|
val videos: List<MomentVideoEntity>? = null,
|
||||||
// 新闻相关字段
|
// 新闻相关字段
|
||||||
val isNews: Boolean = false,
|
val isNews: Boolean = false,
|
||||||
val newsTitle: String = "",
|
val newsTitle: String = "",
|
||||||
@@ -307,13 +343,22 @@ data class MomentEntity(
|
|||||||
val newsSource: String = "",
|
val newsSource: String = "",
|
||||||
val newsCategory: String = "",
|
val newsCategory: String = "",
|
||||||
val newsLanguage: String = "",
|
val newsLanguage: String = "",
|
||||||
val newsContent: String = ""
|
val newsContent: String = "",
|
||||||
|
// 是否已获取完整正文
|
||||||
|
val hasFullText: Boolean = false,
|
||||||
|
// 新闻摘要
|
||||||
|
val summary: String? = null,
|
||||||
|
// 新闻发布时间
|
||||||
|
val publishedAt: String? = null,
|
||||||
|
// 是否已缓存图片
|
||||||
|
val imageCached: Boolean = false
|
||||||
)
|
)
|
||||||
class MomentLoaderExtraArgs(
|
class MomentLoaderExtraArgs(
|
||||||
val explore: Boolean? = false,
|
val explore: Boolean? = false,
|
||||||
val timelineId: Int? = null,
|
val timelineId: Int? = null,
|
||||||
val authorId : Int? = null,
|
val authorId : Int? = null,
|
||||||
val newsOnly: Boolean? = null
|
val newsOnly: Boolean? = null,
|
||||||
|
val videoOnly: Boolean? = null
|
||||||
)
|
)
|
||||||
class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
|
class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
|
||||||
override suspend fun fetchData(
|
override suspend fun fetchData(
|
||||||
@@ -327,7 +372,8 @@ class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
|
|||||||
explore = if (extra.explore == true) "true" else "",
|
explore = if (extra.explore == true) "true" else "",
|
||||||
timelineId = extra.timelineId,
|
timelineId = extra.timelineId,
|
||||||
authorId = extra.authorId,
|
authorId = extra.authorId,
|
||||||
newsFilter = if (extra.newsOnly == true) "news_only" else ""
|
newsFilter = if (extra.newsOnly == true) "news_only" else "",
|
||||||
|
videoFilter = if (extra.videoOnly == true) "video_only" else ""
|
||||||
)
|
)
|
||||||
val data = result.body()?.let {
|
val data = result.body()?.let {
|
||||||
ListContainer(
|
ListContainer(
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
|||||||
import com.aiosman.ravenow.ui.composables.TabItem
|
import com.aiosman.ravenow.ui.composables.TabItem
|
||||||
import com.aiosman.ravenow.ui.composables.UnderlineTabItem
|
import com.aiosman.ravenow.ui.composables.UnderlineTabItem
|
||||||
import com.aiosman.ravenow.ui.composables.rememberDebouncer
|
import com.aiosman.ravenow.ui.composables.rememberDebouncer
|
||||||
|
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.shorts.ShortVideoScreen
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 动态列表
|
* 动态列表
|
||||||
@@ -234,6 +235,7 @@ fun MomentsList() {
|
|||||||
}
|
}
|
||||||
1 -> {
|
1 -> {
|
||||||
// 短视频页面
|
// 短视频页面
|
||||||
|
ShortVideoScreen()
|
||||||
}
|
}
|
||||||
2 -> {
|
2 -> {
|
||||||
// 动态页面 - 暂时显示时间线内容
|
// 动态页面 - 暂时显示时间线内容
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.shorts
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.aiosman.ravenow.GuestLoginCheckOut
|
||||||
|
import com.aiosman.ravenow.GuestLoginCheckOutScene
|
||||||
|
import com.aiosman.ravenow.LocalAppTheme
|
||||||
|
import com.aiosman.ravenow.LocalNavController
|
||||||
|
import com.aiosman.ravenow.entity.MomentEntity
|
||||||
|
import com.aiosman.ravenow.ui.NavigationRoute
|
||||||
|
import com.aiosman.ravenow.ui.index.tabs.shorts.ShortViewCompose
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 短视频页面
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ShortVideoScreen() {
|
||||||
|
val viewModel = ShortVideoViewModel
|
||||||
|
val allMoments = viewModel.moments
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val AppColors = LocalAppTheme.current
|
||||||
|
val momentLoader = viewModel.momentLoader
|
||||||
|
// 记录当前播放的短视频索引,切换 Tab 返回时恢复
|
||||||
|
val currentIndex = rememberSaveable { androidx.compose.runtime.mutableStateOf(0) }
|
||||||
|
|
||||||
|
// 过滤出包含视频的动态
|
||||||
|
val videoMoments = remember(allMoments) {
|
||||||
|
val filtered = allMoments.filter { it.videos != null && it.videos.isNotEmpty() }
|
||||||
|
Log.d("ShortVideoScreen", "过滤视频动态 - 总动态数: ${allMoments.size}, 包含视频的动态数: ${filtered.size}")
|
||||||
|
filtered.forEach { moment ->
|
||||||
|
Log.d("ShortVideoScreen", "视频动态 ID: ${moment.id}, 视频数: ${moment.videos?.size}, 第一个视频URL: ${moment.videos?.firstOrNull()?.url}")
|
||||||
|
}
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始加载数据
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
Log.d("ShortVideoScreen", "开始加载数据")
|
||||||
|
viewModel.refreshPager()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载更多数据
|
||||||
|
LaunchedEffect(allMoments.size, videoMoments.size) {
|
||||||
|
Log.d("ShortVideoScreen", "检查是否需要加载更多 - allMoments: ${allMoments.size}, videoMoments: ${videoMoments.size}, hasNext: ${momentLoader.hasNext}, isLoading: ${momentLoader.isLoading}")
|
||||||
|
if (allMoments.isNotEmpty() && videoMoments.size < 10 && momentLoader.hasNext && !momentLoader.isLoading) {
|
||||||
|
// 如果视频数量少于10个,尝试加载更多
|
||||||
|
Log.d("ShortVideoScreen", "开始加载更多数据")
|
||||||
|
viewModel.loadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
if (momentLoader.isLoading && videoMoments.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(AppColors.background),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = AppColors.main)
|
||||||
|
Text(
|
||||||
|
text = "加载中...",
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
color = AppColors.text,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 错误状态
|
||||||
|
else if (momentLoader.error != null && videoMoments.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(AppColors.background),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "加载失败: ${momentLoader.error}",
|
||||||
|
color = AppColors.error,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 空状态 - 已加载但无视频
|
||||||
|
else if (!momentLoader.isLoading && videoMoments.isEmpty() && allMoments.isNotEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(AppColors.background),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "暂无短视频\n已加载 ${allMoments.size} 条动态,但都不包含视频",
|
||||||
|
color = AppColors.text,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 初始状态 - 还没有加载过数据
|
||||||
|
else if (!momentLoader.isLoading && videoMoments.isEmpty() && allMoments.isEmpty() && momentLoader.error == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(AppColors.background),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "准备加载...",
|
||||||
|
color = AppColors.text,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 显示视频列表
|
||||||
|
else if (videoMoments.isNotEmpty()) {
|
||||||
|
ShortViewCompose(
|
||||||
|
videoMoments = videoMoments,
|
||||||
|
clickItemPosition = currentIndex.value,
|
||||||
|
onLikeClick = { moment ->
|
||||||
|
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
|
||||||
|
navController.navigate(NavigationRoute.Login.route)
|
||||||
|
} else {
|
||||||
|
scope.launch {
|
||||||
|
if (moment.liked) {
|
||||||
|
viewModel.dislikeMoment(moment.id)
|
||||||
|
} else {
|
||||||
|
viewModel.likeMoment(moment.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCommentClick = { moment ->
|
||||||
|
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) {
|
||||||
|
navController.navigate(NavigationRoute.Login.route)
|
||||||
|
} else {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.onAddComment(moment.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFavoriteClick = { moment ->
|
||||||
|
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
|
||||||
|
navController.navigate(NavigationRoute.Login.route)
|
||||||
|
} else {
|
||||||
|
scope.launch {
|
||||||
|
if (moment.isFavorite) {
|
||||||
|
viewModel.unfavoriteMoment(moment.id)
|
||||||
|
} else {
|
||||||
|
viewModel.favoriteMoment(moment.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShareClick = { moment ->
|
||||||
|
// TODO: 实现分享功能
|
||||||
|
},
|
||||||
|
onPageChanged = { idx -> currentIndex.value = idx }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.shorts
|
||||||
|
|
||||||
|
import com.aiosman.ravenow.entity.MomentLoaderExtraArgs
|
||||||
|
import com.aiosman.ravenow.ui.index.tabs.moment.BaseMomentModel
|
||||||
|
import org.greenrobot.eventbus.EventBus
|
||||||
|
|
||||||
|
object ShortVideoViewModel : BaseMomentModel() {
|
||||||
|
init {
|
||||||
|
EventBus.getDefault().register(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun extraArgs(): MomentLoaderExtraArgs {
|
||||||
|
return MomentLoaderExtraArgs(explore = true, videoOnly = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取包含视频的动态列表
|
||||||
|
fun getVideoMoments(): List<com.aiosman.ravenow.entity.MomentEntity> {
|
||||||
|
return moments.filter { it.videos != null && it.videos.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ fun GalleryGrid(
|
|||||||
modifier = Modifier.fillMaxSize().padding(bottom = 8.dp),
|
modifier = Modifier.fillMaxSize().padding(bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(moments) { idx, moment ->
|
itemsIndexed(moments) { idx, moment ->
|
||||||
if (moment != null) {
|
if (moment != null && moment.images.isNotEmpty()) {
|
||||||
val itemDebouncer = rememberDebouncer()
|
val itemDebouncer = rememberDebouncer()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -61,27 +62,65 @@ import androidx.media3.common.util.UnstableApi
|
|||||||
import androidx.media3.common.util.Util
|
import androidx.media3.common.util.Util
|
||||||
import androidx.media3.datasource.DataSource
|
import androidx.media3.datasource.DataSource
|
||||||
import androidx.media3.datasource.DefaultDataSourceFactory
|
import androidx.media3.datasource.DefaultDataSourceFactory
|
||||||
|
import androidx.media3.datasource.DefaultHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import com.aiosman.ravenow.R
|
import com.aiosman.ravenow.R
|
||||||
|
import com.aiosman.ravenow.entity.MomentEntity
|
||||||
import com.aiosman.ravenow.ui.comment.CommentModalContent
|
import com.aiosman.ravenow.ui.comment.CommentModalContent
|
||||||
|
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShortViewCompose(
|
fun ShortViewCompose(
|
||||||
videoItemsUrl: List<String>,
|
videoItemsUrl: List<String> = emptyList(),
|
||||||
|
videoMoments: List<MomentEntity> = emptyList(),
|
||||||
clickItemPosition: Int = 0,
|
clickItemPosition: Int = 0,
|
||||||
videoHeader: @Composable () -> Unit = {},
|
videoHeader: @Composable () -> Unit = {},
|
||||||
videoBottom: @Composable () -> Unit = {}
|
videoBottom: @Composable ((MomentEntity) -> Unit)? = null,
|
||||||
|
onLikeClick: ((MomentEntity) -> Unit)? = null,
|
||||||
|
onCommentClick: ((MomentEntity) -> Unit)? = null,
|
||||||
|
onFavoriteClick: ((MomentEntity) -> Unit)? = null,
|
||||||
|
onShareClick: ((MomentEntity) -> Unit)? = null,
|
||||||
|
onPageChanged: ((Int) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val pagerState: PagerState = run {
|
// 优先使用 videoMoments,如果没有则使用 videoItemsUrl
|
||||||
remember {
|
val items = if (videoMoments.isNotEmpty()) {
|
||||||
PagerState(clickItemPosition, 0, videoItemsUrl.size - 1)
|
videoMoments.mapNotNull { moment ->
|
||||||
|
// MomentVideoEntity 的 url 已经在 toMomentItem() 中添加了 BASE_SERVER 前缀
|
||||||
|
moment.videos?.firstOrNull()?.url
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
videoItemsUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果视频列表为空,显示空状态
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "暂无视频",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 items 不为空后再创建 PagerState
|
||||||
|
val pagerState: PagerState = remember(items.size) {
|
||||||
|
val maxPage = maxOf(0, items.size - 1)
|
||||||
|
PagerState(
|
||||||
|
currentPage = clickItemPosition.coerceIn(0, maxPage),
|
||||||
|
minPage = 0,
|
||||||
|
maxPage = maxPage
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val initialLayout = remember {
|
val initialLayout = remember {
|
||||||
mutableStateOf(true)
|
mutableStateOf(true)
|
||||||
@@ -89,20 +128,39 @@ fun ShortViewCompose(
|
|||||||
val pauseIconVisibleState = remember {
|
val pauseIconVisibleState = remember {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
Pager(
|
Pager(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(RectangleShape),
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
orientation = Orientation.Vertical,
|
orientation = Orientation.Vertical,
|
||||||
offscreenLimit = 1
|
offscreenLimit = 1
|
||||||
) {
|
) {
|
||||||
pauseIconVisibleState.value = false
|
pauseIconVisibleState.value = false
|
||||||
|
val currentMoment = if (videoMoments.isNotEmpty() && page < videoMoments.size) {
|
||||||
|
videoMoments[page]
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步页码到外部(用于返回时恢复进度)
|
||||||
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
|
onPageChanged?.invoke(pagerState.currentPage)
|
||||||
|
}
|
||||||
SingleVideoItemContent(
|
SingleVideoItemContent(
|
||||||
videoItemsUrl[page],
|
videoUrl = items[page],
|
||||||
pagerState,
|
moment = currentMoment,
|
||||||
page,
|
pagerState = pagerState,
|
||||||
initialLayout,
|
pager = page,
|
||||||
pauseIconVisibleState,
|
initialLayout = initialLayout,
|
||||||
videoHeader,
|
pauseIconVisibleState = pauseIconVisibleState,
|
||||||
videoBottom
|
VideoHeader = videoHeader,
|
||||||
|
VideoBottom = videoBottom,
|
||||||
|
onLikeClick = onLikeClick,
|
||||||
|
onCommentClick = onCommentClick,
|
||||||
|
onFavoriteClick = onFavoriteClick,
|
||||||
|
onShareClick = onShareClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,18 +174,39 @@ fun ShortViewCompose(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SingleVideoItemContent(
|
private fun SingleVideoItemContent(
|
||||||
videoUrl: String,
|
videoUrl: String,
|
||||||
|
moment: MomentEntity?,
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
pager: Int,
|
pager: Int,
|
||||||
initialLayout: MutableState<Boolean>,
|
initialLayout: MutableState<Boolean>,
|
||||||
pauseIconVisibleState: MutableState<Boolean>,
|
pauseIconVisibleState: MutableState<Boolean>,
|
||||||
VideoHeader: @Composable() () -> Unit,
|
VideoHeader: @Composable() () -> Unit = {},
|
||||||
VideoBottom: @Composable() () -> Unit,
|
VideoBottom: @Composable ((MomentEntity) -> Unit)? = null,
|
||||||
|
onLikeClick: ((MomentEntity) -> Unit)? = null,
|
||||||
|
onCommentClick: ((MomentEntity) -> Unit)? = null,
|
||||||
|
onFavoriteClick: ((MomentEntity) -> Unit)? = null,
|
||||||
|
onShareClick: ((MomentEntity) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(
|
||||||
VideoPlayer(videoUrl, pagerState, pager, pauseIconVisibleState)
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(RectangleShape) // 确保内容不会溢出到box外
|
||||||
|
) {
|
||||||
|
VideoPlayer(
|
||||||
|
videoUrl = videoUrl,
|
||||||
|
moment = moment,
|
||||||
|
pagerState = pagerState,
|
||||||
|
pager = pager,
|
||||||
|
pauseIconVisibleState = pauseIconVisibleState,
|
||||||
|
onLikeClick = onLikeClick,
|
||||||
|
onCommentClick = onCommentClick,
|
||||||
|
onFavoriteClick = onFavoriteClick,
|
||||||
|
onShareClick = onShareClick
|
||||||
|
)
|
||||||
VideoHeader.invoke()
|
VideoHeader.invoke()
|
||||||
|
if (moment != null && VideoBottom != null) {
|
||||||
Box(modifier = Modifier.align(Alignment.BottomStart)) {
|
Box(modifier = Modifier.align(Alignment.BottomStart)) {
|
||||||
VideoBottom.invoke()
|
VideoBottom.invoke(moment)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (initialLayout.value) {
|
if (initialLayout.value) {
|
||||||
Box(
|
Box(
|
||||||
@@ -143,9 +222,14 @@ private fun SingleVideoItemContent(
|
|||||||
@Composable
|
@Composable
|
||||||
fun VideoPlayer(
|
fun VideoPlayer(
|
||||||
videoUrl: String,
|
videoUrl: String,
|
||||||
|
moment: MomentEntity?,
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
pager: Int,
|
pager: Int,
|
||||||
pauseIconVisibleState: MutableState<Boolean>,
|
pauseIconVisibleState: MutableState<Boolean>,
|
||||||
|
onLikeClick: ((MomentEntity) -> Unit)? = null,
|
||||||
|
onCommentClick: ((MomentEntity) -> Unit)? = null,
|
||||||
|
onFavoriteClick: ((MomentEntity) -> Unit)? = null,
|
||||||
|
onShareClick: ((MomentEntity) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -158,9 +242,20 @@ fun VideoPlayer(
|
|||||||
ExoPlayer.Builder(context)
|
ExoPlayer.Builder(context)
|
||||||
.build()
|
.build()
|
||||||
.apply {
|
.apply {
|
||||||
|
// 创建带有认证头的 HttpDataSource.Factory
|
||||||
|
val httpDataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
|
.setUserAgent(Util.getUserAgent(context, context.packageName))
|
||||||
|
.setDefaultRequestProperties(
|
||||||
|
mapOf(
|
||||||
|
"Authorization" to "Bearer ${com.aiosman.ravenow.AppStore.token ?: ""}",
|
||||||
|
"DEVICE-OS" to "Android"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 创建 DataSource.Factory,使用自定义的 HttpDataSource.Factory
|
||||||
val dataSourceFactory: DataSource.Factory = DefaultDataSourceFactory(
|
val dataSourceFactory: DataSource.Factory = DefaultDataSourceFactory(
|
||||||
context,
|
context,
|
||||||
Util.getUserAgent(context, context.packageName)
|
httpDataSourceFactory
|
||||||
)
|
)
|
||||||
val source = ProgressiveMediaSource.Factory(dataSourceFactory)
|
val source = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||||
.createMediaSource(MediaItem.fromUri(Uri.parse(videoUrl)))
|
.createMediaSource(MediaItem.fromUri(Uri.parse(videoUrl)))
|
||||||
@@ -275,21 +370,54 @@ fun VideoPlayer(
|
|||||||
modifier = Modifier.padding(bottom = 72.dp, end = 12.dp),
|
modifier = Modifier.padding(bottom = 72.dp, end = 12.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
|
if (moment != null) {
|
||||||
|
UserAvatar(avatarUrl = moment.avatar)
|
||||||
|
VideoBtn(
|
||||||
|
icon = R.drawable.rider_pro_video_like,
|
||||||
|
text = formatCount(moment.likeCount)
|
||||||
|
) {
|
||||||
|
moment?.let { onLikeClick?.invoke(it) }
|
||||||
|
}
|
||||||
|
VideoBtn(
|
||||||
|
icon = R.drawable.rider_pro_video_comment,
|
||||||
|
text = formatCount(moment.commentCount)
|
||||||
|
) {
|
||||||
|
moment?.let {
|
||||||
|
showCommentModal = true
|
||||||
|
onCommentClick?.invoke(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VideoBtn(
|
||||||
|
icon = R.drawable.rider_pro_video_favor,
|
||||||
|
text = formatCount(moment.favoriteCount)
|
||||||
|
) {
|
||||||
|
moment?.let { onFavoriteClick?.invoke(it) }
|
||||||
|
}
|
||||||
|
VideoBtn(
|
||||||
|
icon = R.drawable.rider_pro_video_share,
|
||||||
|
text = formatCount(moment.shareCount)
|
||||||
|
) {
|
||||||
|
moment?.let { onShareClick?.invoke(it) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
UserAvatar()
|
UserAvatar()
|
||||||
VideoBtn(icon = R.drawable.rider_pro_video_like, text = "975.9k")
|
VideoBtn(icon = R.drawable.rider_pro_video_like, text = "0")
|
||||||
VideoBtn(icon = R.drawable.rider_pro_video_comment, text = "1896") {
|
VideoBtn(icon = R.drawable.rider_pro_video_comment, text = "0") {
|
||||||
showCommentModal = true
|
showCommentModal = true
|
||||||
}
|
}
|
||||||
VideoBtn(icon = R.drawable.rider_pro_video_favor, text = "234")
|
VideoBtn(icon = R.drawable.rider_pro_video_favor, text = "0")
|
||||||
VideoBtn(icon = R.drawable.rider_pro_video_share, text = "677k")
|
VideoBtn(icon = R.drawable.rider_pro_video_share, text = "0")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// info
|
// info
|
||||||
|
if (moment != null) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.BottomStart
|
contentAlignment = Alignment.BottomStart
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(start = 16.dp, bottom = 16.dp)) {
|
Column(modifier = Modifier.padding(start = 16.dp, bottom = 16.dp)) {
|
||||||
|
if (moment.location.isNotEmpty() && moment.location != "Worldwide") {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(bottom = 8.dp)
|
.padding(bottom = 8.dp)
|
||||||
@@ -306,39 +434,43 @@ fun VideoPlayer(
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(end = 4.dp),
|
modifier = Modifier.padding(end = 4.dp),
|
||||||
text = "USA",
|
text = moment.location,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
style = TextStyle(fontWeight = FontWeight.Bold)
|
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "@Kevinlinpr",
|
text = "@${moment.nickname}",
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
style = TextStyle(fontWeight = FontWeight.Bold)
|
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||||
)
|
)
|
||||||
|
if (moment.momentTextContent.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp), // 确保Text占用可用宽度
|
.padding(top = 4.dp),
|
||||||
text = "Pedro Acosta to join KTM in 2025 on a multi-year deal! \uD83D\uDFE0",
|
text = moment.momentTextContent,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||||
overflow = TextOverflow.Ellipsis, // 超出范围时显示省略号
|
overflow = TextOverflow.Ellipsis,
|
||||||
maxLines = 2 // 最多显示两行
|
maxLines = 2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showCommentModal) {
|
if (showCommentModal && moment != null) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { showCommentModal = false },
|
onDismissRequest = { showCommentModal = false },
|
||||||
containerColor = Color.White,
|
containerColor = Color.White,
|
||||||
sheetState = sheetState
|
sheetState = sheetState
|
||||||
) {
|
) {
|
||||||
CommentModalContent() {
|
CommentModalContent(postId = moment.id) {
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,16 +478,37 @@ fun VideoPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UserAvatar() {
|
fun UserAvatar(avatarUrl: String? = null) {
|
||||||
Image(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(bottom = 16.dp)
|
.padding(bottom = 16.dp)
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.border(width = 3.dp, color = Color.White, shape = RoundedCornerShape(40.dp))
|
.border(width = 3.dp, color = Color.White, shape = RoundedCornerShape(40.dp))
|
||||||
.clip(
|
.clip(RoundedCornerShape(40.dp))
|
||||||
RoundedCornerShape(40.dp)
|
) {
|
||||||
), painter = painterResource(id = R.drawable.default_avatar), contentDescription = ""
|
if (avatarUrl != null && avatarUrl.isNotEmpty()) {
|
||||||
|
CustomAsyncImage(
|
||||||
|
imageUrl = avatarUrl,
|
||||||
|
contentDescription = "用户头像",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
defaultRes = R.drawable.default_avatar
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.default_avatar),
|
||||||
|
contentDescription = "用户头像"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化数字显示
|
||||||
|
private fun formatCount(count: Int): String {
|
||||||
|
return when {
|
||||||
|
count >= 1_000_000 -> String.format("%.1fM", count / 1_000_000.0)
|
||||||
|
count >= 1_000 -> String.format("%.1fK", count / 1_000.0)
|
||||||
|
else -> count.toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
Reference in New Issue
Block a user