Merge remote-tracking branch 'origin/main' into feat/pr-20251104-154907-clean
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.entity.MomentEntity
|
||||
import com.aiosman.ravenow.entity.MomentImageEntity
|
||||
import com.aiosman.ravenow.entity.MomentVideoEntity
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.io.File
|
||||
|
||||
@@ -12,8 +13,12 @@ data class Moment(
|
||||
val id: Long,
|
||||
@SerializedName("textContent")
|
||||
val textContent: String,
|
||||
@SerializedName("url")
|
||||
val url: String? = null,
|
||||
@SerializedName("images")
|
||||
val images: List<Image>,
|
||||
val images: List<Image>? = null,
|
||||
@SerializedName("videos")
|
||||
val videos: List<Video>? = null,
|
||||
@SerializedName("user")
|
||||
val user: User,
|
||||
@SerializedName("likeCount")
|
||||
@@ -24,7 +29,7 @@ data class Moment(
|
||||
val favoriteCount: Long,
|
||||
@SerializedName("isFavorite")
|
||||
val isFavorite: Boolean,
|
||||
@SerializedName("shareCount")
|
||||
@SerializedName("isCommented")
|
||||
val isCommented: Boolean,
|
||||
@SerializedName("commentCount")
|
||||
val commentCount: Long,
|
||||
@@ -47,6 +52,14 @@ data class Moment(
|
||||
val newsLanguage: String? = null,
|
||||
@SerializedName("newsContent")
|
||||
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 {
|
||||
return MomentEntity(
|
||||
@@ -62,7 +75,7 @@ data class Moment(
|
||||
commentCount = commentCount.toInt(),
|
||||
shareCount = 0,
|
||||
favoriteCount = favoriteCount.toInt(),
|
||||
images = images.map {
|
||||
images = images?.map {
|
||||
MomentImageEntity(
|
||||
url = "${ApiClient.BASE_SERVER}${it.url}",
|
||||
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
|
||||
@@ -71,10 +84,28 @@ data class Moment(
|
||||
width = it.width,
|
||||
height = it.height
|
||||
)
|
||||
},
|
||||
} ?: emptyList(),
|
||||
authorId = user.id.toInt(),
|
||||
liked = isLiked,
|
||||
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,
|
||||
newsTitle = newsTitle ?: "",
|
||||
@@ -82,7 +113,11 @@ data class Moment(
|
||||
newsSource = newsSource ?: "",
|
||||
newsCategory = newsCategory ?: "",
|
||||
newsLanguage = newsLanguage ?: "",
|
||||
newsContent = newsContent ?: ""
|
||||
newsContent = newsContent ?: "",
|
||||
hasFullText = hasFullText,
|
||||
summary = summary,
|
||||
publishedAt = publishedAt,
|
||||
imageCached = imageCached
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -92,8 +127,26 @@ data class Image(
|
||||
val id: Long,
|
||||
@SerializedName("url")
|
||||
val url: String,
|
||||
@SerializedName("original_url")
|
||||
val originalUrl: String? = null,
|
||||
@SerializedName("directUrl")
|
||||
val directUrl: String? = null,
|
||||
@SerializedName("thumbnail")
|
||||
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")
|
||||
val blurHash: String?,
|
||||
@SerializedName("width")
|
||||
@@ -102,13 +155,68 @@ data class Image(
|
||||
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(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("nickName")
|
||||
val nickName: String,
|
||||
@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(
|
||||
|
||||
@@ -800,6 +800,7 @@ interface RaveNowAPI {
|
||||
@Query("favouriteUserId") favouriteUserId: Int? = null,
|
||||
@Query("explore") explore: String? = null,
|
||||
@Query("newsFilter") newsFilter: String? = null,
|
||||
@Query("videoFilter") videoFilter: String? = null,
|
||||
): Response<ListContainer<Moment>>
|
||||
|
||||
@Multipart
|
||||
|
||||
@@ -260,6 +260,38 @@ data class MomentImageEntity(
|
||||
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 isFavorite: Boolean = false,
|
||||
// 外部链接
|
||||
val url: String? = null,
|
||||
// 动态视频列表
|
||||
val videos: List<MomentVideoEntity>? = null,
|
||||
// 新闻相关字段
|
||||
val isNews: Boolean = false,
|
||||
val newsTitle: String = "",
|
||||
@@ -307,13 +343,22 @@ data class MomentEntity(
|
||||
val newsSource: String = "",
|
||||
val newsCategory: 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(
|
||||
val explore: Boolean? = false,
|
||||
val timelineId: Int? = null,
|
||||
val authorId : Int? = null,
|
||||
val newsOnly: Boolean? = null
|
||||
val newsOnly: Boolean? = null,
|
||||
val videoOnly: Boolean? = null
|
||||
)
|
||||
class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
|
||||
override suspend fun fetchData(
|
||||
@@ -327,7 +372,8 @@ class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
|
||||
explore = if (extra.explore == true) "true" else "",
|
||||
timelineId = extra.timelineId,
|
||||
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 {
|
||||
ListContainer(
|
||||
|
||||
@@ -59,6 +59,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
||||
import com.aiosman.ravenow.ui.composables.TabItem
|
||||
import com.aiosman.ravenow.ui.composables.UnderlineTabItem
|
||||
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 -> {
|
||||
// 短视频页面
|
||||
ShortVideoScreen()
|
||||
}
|
||||
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),
|
||||
) {
|
||||
itemsIndexed(moments) { idx, moment ->
|
||||
if (moment != null) {
|
||||
if (moment != null && moment.images.isNotEmpty()) {
|
||||
val itemDebouncer = rememberDebouncer()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -61,27 +62,65 @@ import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultDataSourceFactory
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.MomentEntity
|
||||
import com.aiosman.ravenow.ui.comment.CommentModalContent
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ShortViewCompose(
|
||||
videoItemsUrl: List<String>,
|
||||
videoItemsUrl: List<String> = emptyList(),
|
||||
videoMoments: List<MomentEntity> = emptyList(),
|
||||
clickItemPosition: Int = 0,
|
||||
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 {
|
||||
remember {
|
||||
PagerState(clickItemPosition, 0, videoItemsUrl.size - 1)
|
||||
// 优先使用 videoMoments,如果没有则使用 videoItemsUrl
|
||||
val items = if (videoMoments.isNotEmpty()) {
|
||||
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 {
|
||||
mutableStateOf(true)
|
||||
@@ -89,20 +128,39 @@ fun ShortViewCompose(
|
||||
val pauseIconVisibleState = remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
Pager(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RectangleShape),
|
||||
state = pagerState,
|
||||
orientation = Orientation.Vertical,
|
||||
offscreenLimit = 1
|
||||
) {
|
||||
pauseIconVisibleState.value = false
|
||||
val currentMoment = if (videoMoments.isNotEmpty() && page < videoMoments.size) {
|
||||
videoMoments[page]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 同步页码到外部(用于返回时恢复进度)
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
onPageChanged?.invoke(pagerState.currentPage)
|
||||
}
|
||||
SingleVideoItemContent(
|
||||
videoItemsUrl[page],
|
||||
pagerState,
|
||||
page,
|
||||
initialLayout,
|
||||
pauseIconVisibleState,
|
||||
videoHeader,
|
||||
videoBottom
|
||||
videoUrl = items[page],
|
||||
moment = currentMoment,
|
||||
pagerState = pagerState,
|
||||
pager = page,
|
||||
initialLayout = initialLayout,
|
||||
pauseIconVisibleState = pauseIconVisibleState,
|
||||
VideoHeader = videoHeader,
|
||||
VideoBottom = videoBottom,
|
||||
onLikeClick = onLikeClick,
|
||||
onCommentClick = onCommentClick,
|
||||
onFavoriteClick = onFavoriteClick,
|
||||
onShareClick = onShareClick
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,18 +174,39 @@ fun ShortViewCompose(
|
||||
@Composable
|
||||
private fun SingleVideoItemContent(
|
||||
videoUrl: String,
|
||||
moment: MomentEntity?,
|
||||
pagerState: PagerState,
|
||||
pager: Int,
|
||||
initialLayout: MutableState<Boolean>,
|
||||
pauseIconVisibleState: MutableState<Boolean>,
|
||||
VideoHeader: @Composable() () -> Unit,
|
||||
VideoBottom: @Composable() () -> Unit,
|
||||
VideoHeader: @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()) {
|
||||
VideoPlayer(videoUrl, pagerState, pager, pauseIconVisibleState)
|
||||
Box(
|
||||
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()
|
||||
if (moment != null && VideoBottom != null) {
|
||||
Box(modifier = Modifier.align(Alignment.BottomStart)) {
|
||||
VideoBottom.invoke()
|
||||
VideoBottom.invoke(moment)
|
||||
}
|
||||
}
|
||||
if (initialLayout.value) {
|
||||
Box(
|
||||
@@ -143,9 +222,14 @@ private fun SingleVideoItemContent(
|
||||
@Composable
|
||||
fun VideoPlayer(
|
||||
videoUrl: String,
|
||||
moment: MomentEntity?,
|
||||
pagerState: PagerState,
|
||||
pager: Int,
|
||||
pauseIconVisibleState: MutableState<Boolean>,
|
||||
onLikeClick: ((MomentEntity) -> Unit)? = null,
|
||||
onCommentClick: ((MomentEntity) -> Unit)? = null,
|
||||
onFavoriteClick: ((MomentEntity) -> Unit)? = null,
|
||||
onShareClick: ((MomentEntity) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -158,9 +242,20 @@ fun VideoPlayer(
|
||||
ExoPlayer.Builder(context)
|
||||
.build()
|
||||
.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(
|
||||
context,
|
||||
Util.getUserAgent(context, context.packageName)
|
||||
httpDataSourceFactory
|
||||
)
|
||||
val source = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(Uri.parse(videoUrl)))
|
||||
@@ -275,21 +370,54 @@ fun VideoPlayer(
|
||||
modifier = Modifier.padding(bottom = 72.dp, end = 12.dp),
|
||||
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()
|
||||
VideoBtn(icon = R.drawable.rider_pro_video_like, text = "975.9k")
|
||||
VideoBtn(icon = R.drawable.rider_pro_video_comment, text = "1896") {
|
||||
VideoBtn(icon = R.drawable.rider_pro_video_like, text = "0")
|
||||
VideoBtn(icon = R.drawable.rider_pro_video_comment, text = "0") {
|
||||
showCommentModal = true
|
||||
}
|
||||
VideoBtn(icon = R.drawable.rider_pro_video_favor, text = "234")
|
||||
VideoBtn(icon = R.drawable.rider_pro_video_share, text = "677k")
|
||||
VideoBtn(icon = R.drawable.rider_pro_video_favor, text = "0")
|
||||
VideoBtn(icon = R.drawable.rider_pro_video_share, text = "0")
|
||||
}
|
||||
}
|
||||
}
|
||||
// info
|
||||
if (moment != null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomStart
|
||||
) {
|
||||
Column(modifier = Modifier.padding(start = 16.dp, bottom = 16.dp)) {
|
||||
if (moment.location.isNotEmpty() && moment.location != "Worldwide") {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp)
|
||||
@@ -306,39 +434,43 @@ fun VideoPlayer(
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
text = "USA",
|
||||
text = moment.location,
|
||||
fontSize = 12.sp,
|
||||
color = Color.White,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "@Kevinlinpr",
|
||||
text = "@${moment.nickname}",
|
||||
fontSize = 16.sp,
|
||||
color = Color.White,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
if (moment.momentTextContent.isNotEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp), // 确保Text占用可用宽度
|
||||
text = "Pedro Acosta to join KTM in 2025 on a multi-year deal! \uD83D\uDFE0",
|
||||
.padding(top = 4.dp),
|
||||
text = moment.momentTextContent,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
overflow = TextOverflow.Ellipsis, // 超出范围时显示省略号
|
||||
maxLines = 2 // 最多显示两行
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCommentModal) {
|
||||
if (showCommentModal && moment != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showCommentModal = false },
|
||||
containerColor = Color.White,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
CommentModalContent() {
|
||||
CommentModalContent(postId = moment.id) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -346,16 +478,37 @@ fun VideoPlayer(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserAvatar() {
|
||||
Image(
|
||||
fun UserAvatar(avatarUrl: String? = null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.size(40.dp)
|
||||
.border(width = 3.dp, color = Color.White, shape = RoundedCornerShape(40.dp))
|
||||
.clip(
|
||||
RoundedCornerShape(40.dp)
|
||||
), painter = painterResource(id = R.drawable.default_avatar), contentDescription = ""
|
||||
.clip(RoundedCornerShape(40.dp))
|
||||
) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user