Merge branch 'main' into zhong_1

This commit is contained in:
2025-11-10 21:08:24 +08:00
12 changed files with 1853 additions and 11 deletions

View File

@@ -123,12 +123,16 @@ data class Comment(
fun toCommentEntity(): CommentEntity {
return CommentEntity(
id = id,
name = user.nickName,
name = user.nickName ?: "未知用户",
comment = content,
date = ApiClient.dateFromApiString(createdAt),
likes = likeCount,
postId = postId,
avatar = "${ApiClient.BASE_SERVER}${user.avatar}",
avatar = if (user.avatar != null && user.avatar.isNotEmpty()) {
"${ApiClient.BASE_SERVER}${user.avatar}"
} else {
""
},
author = user.id,
liked = isLiked,
unread = isUnread,

View File

@@ -34,7 +34,7 @@ data class Moment(
@SerializedName("commentCount")
val commentCount: Long,
@SerializedName("time")
val time: String,
val time: String?,
@SerializedName("isFollowed")
val isFollowed: Boolean,
// 新闻相关字段
@@ -64,10 +64,18 @@ data class Moment(
fun toMomentItem(): MomentEntity {
return MomentEntity(
id = id.toInt(),
avatar = "${ApiClient.BASE_SERVER}${user.avatar}",
nickname = user.nickName,
avatar = if (user.avatar != null && user.avatar.isNotEmpty()) {
"${ApiClient.BASE_SERVER}${user.avatar}"
} else {
"" // 如果头像为空,使用空字符串
},
nickname = user.nickName ?: "未知用户", // 如果昵称为空,使用默认值
location = "Worldwide",
time = ApiClient.dateFromApiString(time),
time = if (time != null && time.isNotEmpty()) {
ApiClient.dateFromApiString(time)
} else {
java.util.Date() // 如果时间为空,使用当前时间作为默认值
},
followStatus = isFollowed,
momentTextContent = textContent,
momentPicture = R.drawable.default_moment_img,
@@ -77,9 +85,18 @@ data class Moment(
favoriteCount = favoriteCount.toInt(),
images = images?.map {
MomentImageEntity(
url = "${ApiClient.BASE_SERVER}${it.url}",
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
id = it.id,
url = "${ApiClient.BASE_SERVER}${it.url}",
originalUrl = it.originalUrl,
directUrl = it.directUrl,
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
thumbnailDirectUrl = it.thumbnailDirectUrl,
small = it.small,
smallDirectUrl = it.smallDirectUrl,
medium = it.medium,
mediumDirectUrl = it.mediumDirectUrl,
large = it.large,
largeDirectUrl = it.largeDirectUrl,
blurHash = it.blurHash,
width = it.width,
height = it.height
@@ -188,9 +205,9 @@ data class User(
@SerializedName("id")
val id: Long,
@SerializedName("nickName")
val nickName: String,
val nickName: String?,
@SerializedName("avatar")
val avatar: String,
val avatar: String?,
@SerializedName("avatarMedium")
val avatarMedium: String? = null,
@SerializedName("avatarLarge")

View File

@@ -0,0 +1,333 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ApiClient
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import com.google.gson.annotations.SerializedName
/**
* 推荐接口响应
*/
data class RecommendationsResponse(
@SerializedName("success")
val success: Boolean,
@SerializedName("data")
val data: List<RecommendationItem>
)
/**
* 推荐项
*/
data class RecommendationItem(
@SerializedName("type")
val type: String,
@SerializedName("id")
val id: Long,
@SerializedName("data")
val data: Any // 根据type字段动态解析为不同类型
) {
/**
* 将data字段转换为PromptRecommendationData
*/
fun toPromptData(): PromptRecommendationData? {
if (type != "prompt") return null
return try {
val gson = Gson()
val jsonString = gson.toJson(data)
gson.fromJson(jsonString, PromptRecommendationData::class.java)
} catch (e: JsonSyntaxException) {
null
} catch (e: Exception) {
null
}
}
/**
* 将data字段转换为PostRecommendationData (Moment)
*/
fun toPostData(): PostRecommendationData? {
if (type !in listOf("post_normal", "post_video", "post_news", "post_music")) return null
return try {
val gson = Gson()
val jsonString = gson.toJson(data)
gson.fromJson(jsonString, PostRecommendationData::class.java)
} catch (e: JsonSyntaxException) {
null
} catch (e: Exception) {
null
}
}
/**
* 将data字段转换为RoomRecommendationData
*/
fun toRoomData(): RoomRecommendationData? {
if (type != "room") return null
return try {
val gson = Gson()
val jsonString = gson.toJson(data)
gson.fromJson(jsonString, RoomRecommendationData::class.java)
} catch (e: JsonSyntaxException) {
null
} catch (e: Exception) {
null
}
}
}
/**
* Prompt类型推荐数据
*/
data class PromptRecommendationData(
@SerializedName("id")
val id: Long,
@SerializedName("title")
val title: String,
@SerializedName("desc")
val desc: String,
@SerializedName("createdAt")
val createdAt: String,
@SerializedName("updatedAt")
val updatedAt: String,
@SerializedName("avatar")
val avatar: String? = null,
@SerializedName("avatarDirectUrl")
val avatarDirectUrl: String? = null,
@SerializedName("author")
val author: String? = null,
@SerializedName("isPublic")
val isPublic: Boolean = false,
@SerializedName("openId")
val openId: String,
@SerializedName("breakMode")
val breakMode: Boolean = false,
@SerializedName("useCount")
val useCount: Int? = null,
@SerializedName("translations")
val translations: Map<String, Map<String, String>>? = null,
@SerializedName("translation")
val translation: Map<String, String>? = null,
@SerializedName("details")
val details: PromptDetails? = null,
@SerializedName("aiUserProfile")
val aiUserProfile: AIUserProfile? = null,
@SerializedName("creatorProfile")
val creatorProfile: CreatorProfile? = null
)
/**
* Prompt详细信息
*/
data class PromptDetails(
@SerializedName("gender")
val gender: String? = null,
@SerializedName("age")
val age: Int? = null,
@SerializedName("mbti")
val mbti: String? = null,
@SerializedName("constellation")
val constellation: String? = null,
@SerializedName("nickname")
val nickname: String? = null,
@SerializedName("birthday")
val birthday: String? = null,
@SerializedName("signature")
val signature: String? = null,
@SerializedName("nationality")
val nationality: String? = null,
@SerializedName("mainLanguage")
val mainLanguage: String? = null,
@SerializedName("worldview")
val worldview: String? = null,
@SerializedName("habits")
val habits: String? = null,
@SerializedName("hobbies")
val hobbies: String? = null,
@SerializedName("occupation")
val occupation: String? = null,
@SerializedName("expertise")
val expertise: String? = null,
@SerializedName("socialActivityLvl")
val socialActivityLvl: String? = null
)
/**
* AI用户资料
*/
data class AIUserProfile(
@SerializedName("id")
val id: Long,
@SerializedName("username")
val username: String? = null,
@SerializedName("nickname")
val nickname: String,
@SerializedName("avatar")
val avatar: String? = null,
@SerializedName("avatarMedium")
val avatarMedium: String? = null,
@SerializedName("avatarLarge")
val avatarLarge: String? = null,
@SerializedName("avatarDirectUrl")
val avatarDirectUrl: String? = null,
@SerializedName("avatarMediumDirectUrl")
val avatarMediumDirectUrl: String? = null,
@SerializedName("avatarLargeDirectUrl")
val avatarLargeDirectUrl: String? = null,
@SerializedName("bio")
val bio: String? = null,
@SerializedName("trtcUserId")
val trtcUserId: String? = null,
@SerializedName("chatAIId")
val chatAIId: String? = null,
@SerializedName("aiAccount")
val aiAccount: Boolean = true,
@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 CreatorProfile(
@SerializedName("id")
val id: Long,
@SerializedName("username")
val username: String? = null,
@SerializedName("nickname")
val nickname: String,
@SerializedName("avatar")
val avatar: String? = null,
@SerializedName("avatarMedium")
val avatarMedium: String? = null,
@SerializedName("avatarLarge")
val avatarLarge: String? = null,
@SerializedName("avatarDirectUrl")
val avatarDirectUrl: String? = null,
@SerializedName("avatarMediumDirectUrl")
val avatarMediumDirectUrl: String? = null,
@SerializedName("avatarLargeDirectUrl")
val avatarLargeDirectUrl: String? = null,
@SerializedName("bio")
val bio: String? = null,
@SerializedName("trtcUserId")
val trtcUserId: String? = null,
@SerializedName("chatAIId")
val chatAIId: 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
)
/**
* Post类型推荐数据复用Moment中的结构
* 支持 post_normal, post_video, post_news, post_music
*/
typealias PostRecommendationData = Moment
/**
* Room类型推荐数据
*/
data class RoomRecommendationData(
@SerializedName("id")
val id: Long,
@SerializedName("name")
val name: String,
@SerializedName("description")
val description: String,
@SerializedName("createdAt")
val createdAt: String? = null,
@SerializedName("updatedAt")
val updatedAt: String? = null,
@SerializedName("cover")
val cover: String? = null,
@SerializedName("coverDirectUrl")
val coverDirectUrl: String? = null,
@SerializedName("avatar")
val avatar: String? = null,
@SerializedName("avatarDirectUrl")
val avatarDirectUrl: String? = null,
@SerializedName("recommendBanner")
val recommendBanner: String? = null,
@SerializedName("trtcRoomId")
val trtcRoomId: String? = null,
@SerializedName("trtcType")
val trtcType: String? = null,
@SerializedName("isRecommended")
val isRecommended: Boolean = false,
@SerializedName("allowInHot")
val allowInHot: Boolean = false,
@SerializedName("language")
val language: String? = null,
@SerializedName("maxTotal")
val maxTotal: Int? = null,
@SerializedName("privateFeePaid")
val privateFeePaid: Boolean = false
)
/**
* 推荐Service接口
*/
interface RecommendationService {
/**
* 获取推荐列表
* @param pool 推荐池名称(可选)
* @param count 返回数量可选默认10最大50
* @param lang 语言代码(可选)
* @param promptReplaceTrans 是否用翻译覆盖原字段可选仅对prompt类型生效
* @return 推荐项列表
*/
suspend fun getRecommendations(
pool: String? = null,
count: Int? = null,
lang: String? = null,
promptReplaceTrans: Boolean? = null
): List<RecommendationItem>
}
/**
* 推荐Service实现
*/
class RecommendationServiceImpl : RecommendationService {
override suspend fun getRecommendations(
pool: String?,
count: Int?,
lang: String?,
promptReplaceTrans: Boolean?
): List<RecommendationItem> {
val resp = ApiClient.api.getRecommendations(
pool = pool,
count = count,
lang = lang,
promptReplaceTrans = promptReplaceTrans
)
if (resp.isSuccessful) {
val body = resp.body()
if (body != null && body.success) {
return body.data
}
}
throw ServiceException("Failed to get recommendations")
}
}

View File

@@ -10,6 +10,7 @@ import com.aiosman.ravenow.data.Comment
import com.aiosman.ravenow.data.DataContainer
import com.aiosman.ravenow.data.ListContainer
import com.aiosman.ravenow.data.Moment
import com.aiosman.ravenow.data.RecommendationsResponse
import com.aiosman.ravenow.data.Room
import com.aiosman.ravenow.entity.ChatNotification
import com.aiosman.ravenow.data.membership.MembershipConfigData
@@ -1596,5 +1597,13 @@ interface RaveNowAPI {
@GET("recommendations")
suspend fun getRecommendations(
@Query("pool") pool: String? = null,
@Query("count") count: Int? = null,
@Query("lang") lang: String? = null,
@Query("promptReplaceTrans") promptReplaceTrans: Boolean? = null
): Response<RecommendationsResponse>
}

View File

@@ -250,8 +250,26 @@ data class MomentImageEntity(
val id: Long,
// 图片URL
val url: String,
// 原始图片URL
val originalUrl: String? = null,
// 直接访问URL
val directUrl: String? = null,
// 缩略图URL
val thumbnail: String,
// 缩略图直接访问URL
val thumbnailDirectUrl: String? = null,
// 小尺寸图片URL
val small: String? = null,
// 小尺寸图片直接访问URL
val smallDirectUrl: String? = null,
// 中尺寸图片URL
val medium: String? = null,
// 中尺寸图片直接访问URL
val mediumDirectUrl: String? = null,
// 大尺寸图片URL
val large: String? = null,
// 大尺寸图片直接访问URL
val largeDirectUrl: String? = null,
// 图片BlurHash
val blurHash: String? = null,
// 宽度

View File

@@ -48,6 +48,7 @@ import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.Explore
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.hot.HotMomentsList
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentsList
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.news.NewsScreen
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend.RecommendScreen
import com.aiosman.ravenow.ui.index.tabs.search.SearchViewModel
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.launch
@@ -232,7 +233,7 @@ fun MomentsList() {
when (it) {
0 -> {
// 推荐页面
NewsScreen()
RecommendScreen()
}
1 -> {
// 短视频页面

View File

@@ -0,0 +1,37 @@
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.news.NewsItem
/**
* 新闻推荐Item组件
* 复用 NewsItem 组件
* 注意:如果新闻没有图片,则不显示(返回空)
*/
@Composable
fun NewsRecommendationItem(
moment: MomentEntity,
onCommentClick: () -> Unit = {},
onReadFullClick: () -> Unit = {},
onLikeClick: () -> Unit = {},
onFavoriteClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
// 如果新闻没有图片,则不显示
if (moment.images.isEmpty()) {
return
}
NewsItem(
moment = moment,
modifier = modifier.fillMaxSize(),
onCommentClick = onCommentClick,
onReadFullClick = onReadFullClick,
onLikeClick = onLikeClick,
onFavoriteClick = onFavoriteClick
)
}

View File

@@ -0,0 +1,276 @@
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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
/**
* 动态推荐Item组件post_normal
* 参照短视频界面样式,但用图片替代视频播放器
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PostRecommendationItem(
moment: MomentEntity,
onLikeClick: ((MomentEntity) -> Unit)? = null,
onCommentClick: ((MomentEntity) -> Unit)? = null,
onFavoriteClick: ((MomentEntity) -> Unit)? = null,
onShareClick: ((MomentEntity) -> Unit)? = null,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var showCommentModal by remember { mutableStateOf(false) }
var sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
// 图片列表
val images = moment.images
val imageCount = images.size
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
) {
// 图片显示区域(替代视频播放器)
if (imageCount > 0) {
// 只显示第一张图片,优先使用 thumbnailDirectUrl
val imageUrl = images[0].thumbnailDirectUrl
?: images[0].directUrl
?: images[0].url
CustomAsyncImage(
context = context,
imageUrl = imageUrl,
contentDescription = "动态图片",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
blurHash = images[0].blurHash
)
} else {
// 没有图片时显示默认图片
Image(
painter = painterResource(id = R.drawable.default_moment_img),
contentDescription = "默认图片",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
// 底部左侧:用户信息和文字内容
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomStart
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, bottom = 16.dp)
) {
// 用户头像和昵称
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 8.dp)
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f))
) {
CustomAsyncImage(
imageUrl = moment.avatar,
contentDescription = "用户头像",
modifier = Modifier.fillMaxSize(),
defaultRes = R.drawable.default_avatar
)
}
Text(
text = "@${moment.nickname}",
modifier = Modifier.padding(start = 8.dp),
fontSize = 16.sp,
color = Color.White,
style = TextStyle(fontWeight = FontWeight.Bold)
)
}
// 文字内容
if (moment.momentTextContent.isNotEmpty()) {
Text(
modifier = Modifier
.fillMaxWidth(0.8f)
.padding(top = 4.dp),
text = moment.momentTextContent,
fontSize = 16.sp,
color = Color.White,
style = TextStyle(fontWeight = FontWeight.Bold),
overflow = TextOverflow.Ellipsis,
maxLines = 2
)
}
}
}
// 底部右侧:互动按钮
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
Column(
modifier = Modifier
.padding(bottom = 72.dp, end = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 用户头像
UserAvatar(avatarUrl = moment.avatar)
// 点赞
VideoBtn(
icon = if (moment.liked) R.drawable.rider_pro_video_like else R.drawable.rider_pro_video_like,
text = formatCount(moment.likeCount)
) {
onLikeClick?.invoke(moment)
}
// 评论
VideoBtn(
icon = R.drawable.rider_pro_video_comment,
text = formatCount(moment.commentCount)
) {
showCommentModal = true
onCommentClick?.invoke(moment)
}
// 收藏
VideoBtn(
icon = R.drawable.rider_pro_video_favor,
text = formatCount(moment.favoriteCount)
) {
onFavoriteClick?.invoke(moment)
}
// 分享
VideoBtn(
icon = R.drawable.rider_pro_video_share,
text = formatCount(moment.shareCount)
) {
onShareClick?.invoke(moment)
}
}
}
}
// 评论弹窗
if (showCommentModal) {
ModalBottomSheet(
onDismissRequest = { showCommentModal = false },
containerColor = Color.White,
sheetState = sheetState
) {
CommentModalContent(postId = moment.id) {
// 评论添加后的回调
}
}
}
}
@Composable
private fun UserAvatar(avatarUrl: String? = null) {
Box(
modifier = Modifier
.padding(bottom = 16.dp)
.size(40.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f))
) {
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
private fun VideoBtn(
icon: Int,
text: String,
onClick: (() -> Unit)? = null
) {
Column(
modifier = Modifier
.padding(bottom = 16.dp)
.noRippleClickable {
onClick?.invoke()
},
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
modifier = Modifier.size(36.dp),
painter = painterResource(id = icon),
contentDescription = ""
)
Text(
text = text,
fontSize = 11.sp,
color = Color.White,
style = TextStyle(fontWeight = FontWeight.Bold)
)
}
}

View File

@@ -0,0 +1,147 @@
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
/**
* Prompt推荐Item组件
* 精美的AI Agent卡片样式
*/
@Composable
fun PromptRecommendationItem(
id: Long,
title: String,
desc: String,
avatar: String?,
openId: String,
modifier: Modifier = Modifier
) {
val AppColors = LocalAppTheme.current
val navController = LocalNavController.current
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
) {
// 背景大图
CustomAsyncImage(
imageUrl = avatar,
contentDescription = "AI Agent 头像",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
defaultRes = R.drawable.default_avatar
)
// 渐变遮罩(底部深色)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
),
startY = 200f
)
)
)
// 内容区域
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomStart
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, bottom = 40.dp)
) {
// AI Agent 标签
Surface(
modifier = Modifier.padding(bottom = 16.dp),
shape = RoundedCornerShape(8.dp),
color = Color.White.copy(alpha = 0.2f)
) {
Text(
text = "✨ AI Agent",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
fontSize = 12.sp,
color = Color.White,
fontWeight = FontWeight.Medium
)
}
// 名称
Text(
text = title,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.padding(bottom = 12.dp)
)
// 描述
Text(
text = desc,
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.9f),
lineHeight = 22.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 24.dp)
)
// Start chatting 按钮
Button(
onClick = {
// 导航到聊天页面
navController.navigate("${NavigationRoute.Chat.route}/$openId")
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(24.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color.Black
)
) {
Text(
text = "💬 Start chatting",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
}
}

View File

@@ -0,0 +1,288 @@
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
import androidx.compose.foundation.ExperimentalFoundationApi
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.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
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.rememberCoroutineScope
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.composables.rememberDebouncer
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend.RecommendViewModel
import kotlinx.coroutines.launch
/**
* 推荐界面主组件
* 使用 VerticalPager 实现上下滑动切换页面
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RecommendScreen() {
val AppColors = LocalAppTheme.current
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val recommendationsList = RecommendViewModel.recommendations
val loading = RecommendViewModel.isLoading
val errorMessage = RecommendViewModel.error
val hasMore = RecommendViewModel.hasNext
// 垂直翻页状态
val pagerState = rememberPagerState(pageCount = { recommendationsList.size })
// 防抖器
val likeDebouncer = rememberDebouncer()
val favoriteDebouncer = rememberDebouncer()
// 初始化加载数据
LaunchedEffect(Unit) {
RecommendViewModel.refreshRecommendations()
}
// 当翻页接近末尾时加载更多
LaunchedEffect(pagerState.currentPage, recommendationsList.size) {
if (recommendationsList.isNotEmpty() && pagerState.currentPage >= recommendationsList.size - 2 && hasMore) {
RecommendViewModel.loadMore()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(AppColors.background)
) {
when {
// 加载中
loading && recommendationsList.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(color = AppColors.main)
Text(
text = "加载中...",
modifier = Modifier.padding(top = 16.dp),
color = AppColors.text,
fontSize = 14.sp
)
}
}
}
// 错误状态
errorMessage != null && recommendationsList.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "加载失败: $errorMessage",
color = AppColors.error,
fontSize = 14.sp
)
}
}
// 空状态
recommendationsList.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "暂无推荐内容",
color = AppColors.text,
fontSize = 16.sp
)
}
}
// 显示推荐列表
else -> {
VerticalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
val item = recommendationsList.getOrNull(page) ?: return@VerticalPager
Box(modifier = Modifier.fillMaxSize()) {
when (item) {
is RecommendationDisplayItem.Post -> {
val moment = item.moment
when {
// 新闻类型
moment.isNews -> {
NewsRecommendationItem(
moment = moment,
onCommentClick = {
// TODO: 打开评论弹窗
},
onReadFullClick = {
// TODO: 打开全文弹窗
},
onLikeClick = {
likeDebouncer {
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
scope.launch {
if (moment.liked) {
RecommendViewModel.dislikeMoment(moment.id)
} else {
RecommendViewModel.likeMoment(moment.id)
}
}
}
}
},
onFavoriteClick = {
favoriteDebouncer {
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
scope.launch {
if (moment.isFavorite) {
RecommendViewModel.unfavoriteMoment(moment.id)
} else {
RecommendViewModel.favoriteMoment(moment.id)
}
}
}
}
}
)
}
// 短视频类型
!moment.videos.isNullOrEmpty() -> {
VideoRecommendationItem(
moment = moment,
isVisible = pagerState.currentPage == page,
modifier = Modifier.fillMaxSize(),
onLikeClick = { m ->
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
scope.launch {
if (m.liked) {
RecommendViewModel.dislikeMoment(m.id)
} else {
RecommendViewModel.likeMoment(m.id)
}
}
}
},
onCommentClick = { m ->
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
scope.launch {
RecommendViewModel.onAddComment(m.id)
}
}
},
onFavoriteClick = { m ->
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
scope.launch {
if (m.isFavorite) {
RecommendViewModel.unfavoriteMoment(m.id)
} else {
RecommendViewModel.favoriteMoment(m.id)
}
}
}
},
onShareClick = { _ ->
// TODO: 实现分享功能
}
)
}
// 普通动态类型
else -> {
PostRecommendationItem(
moment = moment,
modifier = Modifier.fillMaxSize(),
onLikeClick = { m ->
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
scope.launch {
if (m.liked) {
RecommendViewModel.dislikeMoment(m.id)
} else {
RecommendViewModel.likeMoment(m.id)
}
}
}
},
onCommentClick = { m ->
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
scope.launch {
RecommendViewModel.onAddComment(m.id)
}
}
},
onFavoriteClick = { m ->
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
scope.launch {
if (m.isFavorite) {
RecommendViewModel.unfavoriteMoment(m.id)
} else {
RecommendViewModel.favoriteMoment(m.id)
}
}
}
},
onShareClick = { _ ->
// TODO: 实现分享功能
}
)
}
}
}
is RecommendationDisplayItem.Prompt -> {
PromptRecommendationItem(
id = item.id,
title = item.title,
desc = item.desc,
avatar = item.avatar,
openId = item.openId,
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,311 @@
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.data.MomentService
import com.aiosman.ravenow.entity.MomentServiceImpl
import com.aiosman.ravenow.data.PostRecommendationData
import com.aiosman.ravenow.data.RecommendationItem
import com.aiosman.ravenow.data.RecommendationService
import com.aiosman.ravenow.data.RecommendationServiceImpl
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.entity.MomentEntity
import kotlinx.coroutines.launch
/**
* 推荐界面统一数据模型
*/
sealed class RecommendationDisplayItem {
data class Post(val moment: MomentEntity) : RecommendationDisplayItem()
data class Prompt(
val id: Long,
val title: String,
val desc: String,
val avatar: String?,
val openId: String
) : RecommendationDisplayItem()
}
object RecommendViewModel : ViewModel() {
private val recommendationService: RecommendationService = RecommendationServiceImpl()
private val momentService: MomentService = MomentServiceImpl()
var recommendations by mutableStateOf<List<RecommendationDisplayItem>>(listOf())
private set
var isLoading by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
private set
var hasNext by mutableStateOf(true)
private set
private var currentPage = 1
private val pageSize = 20
private var isFirstLoad = true
/**
* 刷新推荐列表
*/
fun refreshRecommendations() {
if (!isFirstLoad) {
return
}
if (isLoading) {
return
}
isFirstLoad = false
currentPage = 1
recommendations = listOf()
hasNext = true
viewModelScope.launch {
loadRecommendations()
}
}
/**
* 加载更多推荐
*/
fun loadMore() {
if (isFirstLoad) {
refreshRecommendations()
return
}
if (!hasNext || isLoading) {
return
}
viewModelScope.launch {
currentPage++
loadRecommendations(append = true)
}
}
private suspend fun loadRecommendations(append: Boolean = false) {
isLoading = true
error = null
try {
Log.d("RecommendViewModel", "开始加载推荐数据append=$append, pageSize=$pageSize")
val items = recommendationService.getRecommendations(
count = pageSize,
lang = null,
promptReplaceTrans = null
)
Log.d("RecommendViewModel", "获取到推荐数据: ${items.size}")
// 过滤掉 post_music 和 room 类型
val filteredItems = items.filter {
it.type !in listOf("post_music", "room")
}
Log.d("RecommendViewModel", "过滤后数据: ${filteredItems.size}")
val displayItems = filteredItems.mapNotNull { item ->
try {
when (item.type) {
"post_normal", "post_video", "post_news" -> {
val postData = item.toPostData()
if (postData != null) {
RecommendationDisplayItem.Post(postData.toMomentItem())
} else {
Log.w("RecommendViewModel", "无法转换 post 数据type=${item.type}, id=${item.id}")
null
}
}
"prompt" -> {
val promptData = item.toPromptData()
if (promptData != null) {
// 优先使用 aiUserProfile 的 aiRoleAvatarMediumDirectUrl
val avatarUrl = promptData.aiUserProfile?.aiRoleAvatarMediumDirectUrl
?: promptData.aiUserProfile?.aiRoleAvatarDirectUrl
?: promptData.avatarDirectUrl
?: promptData.avatar?.let { "${com.aiosman.ravenow.data.api.ApiClient.BASE_SERVER}$it" }
RecommendationDisplayItem.Prompt(
id = promptData.id,
title = promptData.title,
desc = promptData.desc,
avatar = avatarUrl,
openId = promptData.openId ?: ""
)
} else {
Log.w("RecommendViewModel", "无法转换 prompt 数据id=${item.id}")
null
}
}
else -> {
Log.w("RecommendViewModel", "未知类型: ${item.type}, id=${item.id}")
null
}
}
} catch (e: Exception) {
Log.e("RecommendViewModel", "转换推荐项失败type=${item.type}, id=${item.id}", e)
null
}
}
Log.d("RecommendViewModel", "成功转换数据: ${displayItems.size}")
if (append) {
recommendations = recommendations + displayItems
} else {
recommendations = displayItems
}
hasNext = items.size >= pageSize
Log.d("RecommendViewModel", "加载完成,当前总数: ${recommendations.size}, hasNext=$hasNext")
} catch (e: ServiceException) {
val errorMsg = e.message ?: "加载推荐失败"
error = errorMsg
Log.e("RecommendViewModel", "ServiceException: $errorMsg", e)
e.printStackTrace()
if (!append) {
isFirstLoad = true // 允许重试
}
} catch (e: Exception) {
val errorMsg = e.message ?: "加载推荐时发生未知错误"
error = errorMsg
Log.e("RecommendViewModel", "Exception: $errorMsg", e)
e.printStackTrace()
if (!append) {
isFirstLoad = true // 允许重试
}
} finally {
isLoading = false
}
}
/**
* 点赞动态
*/
suspend fun likeMoment(id: Int) {
try {
momentService.likeMoment(id)
updateMomentLike(id, true)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 取消点赞动态
*/
suspend fun dislikeMoment(id: Int) {
try {
momentService.dislikeMoment(id)
updateMomentLike(id, false)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 收藏动态
*/
suspend fun favoriteMoment(id: Int) {
try {
momentService.favoriteMoment(id)
updateMomentFavorite(id, true)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 取消收藏动态
*/
suspend fun unfavoriteMoment(id: Int) {
try {
momentService.unfavoriteMoment(id)
updateMomentFavorite(id, false)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 添加评论
*/
fun onAddComment(id: Int) {
updateMomentCommentCount(id, 1)
}
/**
* 更新动态点赞状态
*/
private fun updateMomentLike(id: Int, isLike: Boolean) {
recommendations = recommendations.map { item ->
if (item is RecommendationDisplayItem.Post && item.moment.id == id) {
val moment = item.moment
RecommendationDisplayItem.Post(
moment.copy(
liked = isLike,
likeCount = moment.likeCount + if (isLike) 1 else -1
)
)
} else {
item
}
}
}
/**
* 更新动态收藏状态
*/
private fun updateMomentFavorite(id: Int, isFavorite: Boolean) {
recommendations = recommendations.map { item ->
if (item is RecommendationDisplayItem.Post && item.moment.id == id) {
val moment = item.moment
RecommendationDisplayItem.Post(
moment.copy(
isFavorite = isFavorite,
favoriteCount = moment.favoriteCount + if (isFavorite) 1 else -1
)
)
} else {
item
}
}
}
/**
* 更新动态评论数
*/
private fun updateMomentCommentCount(id: Int, delta: Int) {
recommendations = recommendations.map { item ->
if (item is RecommendationDisplayItem.Post && item.moment.id == id) {
val moment = item.moment
RecommendationDisplayItem.Post(
moment.copy(
commentCount = (moment.commentCount + delta).coerceAtLeast(0)
)
)
} else {
item
}
}
}
/**
* 重置状态
*/
fun reset() {
recommendations = listOf()
isLoading = false
error = null
hasNext = true
currentPage = 1
isFirstLoad = true
}
}

View File

@@ -0,0 +1,401 @@
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
import android.net.Uri
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
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
import androidx.compose.runtime.rememberCoroutineScope
/**
* 短视频推荐Item组件
* 不包含内部 Pager由外层的 VerticalPager 处理滑动
*/
@OptIn(UnstableApi::class, ExperimentalMaterial3Api::class)
@Composable
fun VideoRecommendationItem(
moment: MomentEntity,
isVisible: Boolean = true, // 是否可见,用于控制播放/暂停
onLikeClick: ((MomentEntity) -> Unit)? = null,
onCommentClick: ((MomentEntity) -> Unit)? = null,
onFavoriteClick: ((MomentEntity) -> Unit)? = null,
onShareClick: ((MomentEntity) -> Unit)? = null,
modifier: Modifier = Modifier
) {
// 检查是否有视频
val videoUrl = moment.videos?.firstOrNull()?.url
if (videoUrl == null) {
return
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
val lifecycleOwner = LocalLifecycleOwner.current
var showCommentModal by remember { mutableStateOf(false) }
var sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
var pauseIconVisibleState by remember { mutableStateOf(false) }
val exoPlayer = remember(videoUrl) {
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,
httpDataSourceFactory
)
val source = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(Uri.parse(videoUrl)))
this.prepare(source)
}
}
// 根据 isVisible 控制播放/暂停
LaunchedEffect(isVisible) {
if (isVisible) {
exoPlayer.playWhenReady = true
exoPlayer.play()
} else {
exoPlayer.pause()
}
}
exoPlayer.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
exoPlayer.repeatMode = Player.REPEAT_MODE_ONE
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
) {
// 视频播放器
Box(
modifier = Modifier
.fillMaxSize()
) {
AndroidView(
factory = { ctx ->
FrameLayout(ctx).apply {
setBackgroundColor(Color.Black.toArgb())
val view = PlayerView(ctx).apply {
hideController()
useController = false
player = exoPlayer
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
Gravity.CENTER
)
}
addView(view)
}
},
modifier = Modifier
.fillMaxSize()
.noRippleClickable {
pauseIconVisibleState = true
exoPlayer.pause()
scope.launch {
delay(100)
if (exoPlayer.isPlaying) {
exoPlayer.pause()
} else {
pauseIconVisibleState = false
exoPlayer.play()
}
}
}
)
if (pauseIconVisibleState) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier
.align(Alignment.Center)
.size(80.dp),
tint = Color.White
)
}
}
// 底部右侧:互动按钮
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
Column(
modifier = Modifier
.padding(bottom = 72.dp, end = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
UserAvatar(avatarUrl = moment.avatar)
VideoBtn(
icon = R.drawable.rider_pro_video_like,
text = formatCount(moment.likeCount)
) {
onLikeClick?.invoke(moment)
}
VideoBtn(
icon = R.drawable.rider_pro_video_comment,
text = formatCount(moment.commentCount)
) {
showCommentModal = true
onCommentClick?.invoke(moment)
}
VideoBtn(
icon = R.drawable.rider_pro_video_favor,
text = formatCount(moment.favoriteCount)
) {
onFavoriteClick?.invoke(moment)
}
VideoBtn(
icon = R.drawable.rider_pro_video_share,
text = formatCount(moment.shareCount)
) {
onShareClick?.invoke(moment)
}
}
}
// 底部左侧:用户信息和文字内容
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomStart
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, bottom = 16.dp)
) {
if (moment.location.isNotEmpty() && moment.location != "Worldwide") {
Row(
modifier = Modifier
.padding(bottom = 8.dp)
.background(
color = Color.Gray.copy(alpha = 0.5f),
shape = RoundedCornerShape(8.dp)
)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Image(
modifier = Modifier
.size(20.dp)
.padding(end = 6.dp),
painter = painterResource(id = R.drawable.rider_pro_video_location),
contentDescription = ""
)
Text(
text = moment.location,
fontSize = 12.sp,
color = Color.White,
style = TextStyle(fontWeight = FontWeight.Bold)
)
}
}
Text(
text = "@${moment.nickname}",
fontSize = 16.sp,
color = Color.White,
style = TextStyle(fontWeight = FontWeight.Bold)
)
if (moment.momentTextContent.isNotEmpty()) {
Text(
modifier = Modifier
.fillMaxWidth(0.8f)
.padding(top = 4.dp),
text = moment.momentTextContent,
fontSize = 16.sp,
color = Color.White,
style = TextStyle(fontWeight = FontWeight.Bold),
overflow = TextOverflow.Ellipsis,
maxLines = 2
)
}
}
}
}
// 评论弹窗
if (showCommentModal) {
ModalBottomSheet(
onDismissRequest = { showCommentModal = false },
containerColor = Color.White,
sheetState = sheetState
) {
CommentModalContent(postId = moment.id) {
// 评论添加后的回调
}
}
}
// 释放 ExoPlayer
DisposableEffect(videoUrl) {
onDispose {
exoPlayer.release()
}
}
// 生命周期管理
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer.pause()
}
Lifecycle.Event.ON_RESUME -> {
if (isVisible) {
exoPlayer.play()
}
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
@Composable
private fun UserAvatar(avatarUrl: String? = null) {
Box(
modifier = Modifier
.padding(bottom = 16.dp)
.size(40.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f))
) {
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
private fun VideoBtn(
icon: Int,
text: String,
onClick: (() -> Unit)? = null
) {
Column(
modifier = Modifier
.padding(bottom = 16.dp)
.noRippleClickable {
onClick?.invoke()
},
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
modifier = Modifier.size(36.dp),
painter = painterResource(id = icon),
contentDescription = ""
)
Text(
text = text,
fontSize = 11.sp,
color = Color.White,
style = TextStyle(fontWeight = FontWeight.Bold)
)
}
}