加入短视频

This commit is contained in:
2024-07-12 01:36:59 +08:00
parent e7d81dc827
commit 37a16dd683
9 changed files with 617 additions and 8 deletions

View File

@@ -62,7 +62,12 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation (libs.androidx.paging.compose)
implementation(libs.androidx.paging.runtime)
implementation("com.google.maps.android:maps-compose:4.3.3")
implementation(libs.maps.compose)
implementation(libs.accompanist.systemuicontroller)
implementation(libs.androidx.media3.exoplayer) // 核心播放器
implementation(libs.androidx.media3.ui) // UI组件可选
implementation(libs.androidx.media3.session)
implementation(libs.androidx.activity.ktx) // 用于媒体会话(可选)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -1,5 +1,6 @@
package com.aiosman.riderpro
import android.app.Activity
import android.app.StatusBarManager
import android.os.Bundle
import android.widget.HorizontalScrollView
@@ -8,6 +9,9 @@ import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -60,6 +64,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.aiosman.riderpro.ui.theme.RiderProTheme
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.GoogleMap
@@ -82,7 +87,14 @@ class MainActivity : ComponentActivity() {
@Composable
fun NavigationController(navController: NavHostController){
NavHost(
navController = navController, startDestination = NavigationItem.Home.route){
navController = navController,
startDestination = NavigationItem.Home.route,
enterTransition = {
fadeIn(animationSpec = tween(300))
},
exitTransition = {
fadeOut(animationSpec = tween(300))
}){
composable(route = NavigationItem.Home.route){
Home()
}
@@ -93,7 +105,7 @@ fun NavigationController(navController: NavHostController){
Add()
}
composable(route = NavigationItem.Message.route){
Message()
Video()
}
composable(route = NavigationItem.Profile.route){
Profile()
@@ -124,6 +136,7 @@ fun Navigation(){
){
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val systemUiController = rememberSystemUiController()
item.forEach{ it ->
NavigationBarItem(
selected = currentRoute == it.route ,
@@ -131,6 +144,23 @@ fun Navigation(){
if(currentRoute != it.route){
navController.navigate(it.route)
}
when (it.route) {
NavigationItem.Add.route -> {
systemUiController.setSystemBarsColor(
color = Color.Black
)
}
NavigationItem.Message.route -> {
systemUiController.setSystemBarsColor(
color = Color.Black
)
}
else -> {
systemUiController.setSystemBarsColor(
color = Color.Transparent
)
}
}
},
colors = NavigationBarItemColors(
selectedIconColor = Color.Red,
@@ -193,6 +223,17 @@ fun Add(){
}
}
@Composable
fun Video(){
Column (
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
){
ShortVideo()
}
}
@Composable
fun Message(){
Column (

View File

@@ -0,0 +1,14 @@
package com.aiosman.riderpro
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier = composed {
this.clickable(indication = null,
interactionSource = remember { MutableInteractionSource() }) {
onClick()
}
}

View File

@@ -0,0 +1,232 @@
package com.aiosman.riderpro
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.unit.Density
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
class PagerState(
currentPage: Int = 0,
minPage: Int = 0,
maxPage: Int = 0
) {
private var _minPage by mutableStateOf(minPage)
var minPage: Int
get() = _minPage
set(value) {
_minPage = value.coerceAtMost(_maxPage)
_currentPage = _currentPage.coerceIn(_minPage, _maxPage)
}
private var _maxPage by mutableStateOf(maxPage, structuralEqualityPolicy())
var maxPage: Int
get() = _maxPage
set(value) {
_maxPage = value.coerceAtLeast(_minPage)
_currentPage = _currentPage.coerceIn(_minPage, maxPage)
}
private var _currentPage by mutableStateOf(currentPage.coerceIn(minPage, maxPage))
var currentPage: Int
get() = _currentPage
set(value) {
_currentPage = value.coerceIn(minPage, maxPage)
}
enum class SelectionState { Selected, Undecided }
var selectionState by mutableStateOf(SelectionState.Selected)
suspend inline fun <R> selectPage(block: PagerState.() -> R): R = try {
selectionState = SelectionState.Undecided
block()
} finally {
selectPage()
}
suspend fun selectPage() {
currentPage -= currentPageOffset.roundToInt()
snapToOffset(0f)
selectionState = SelectionState.Selected
}
private var _currentPageOffset = Animatable(0f).apply {
updateBounds(-1f, 1f)
}
val currentPageOffset: Float
get() = _currentPageOffset.value
suspend fun snapToOffset(offset: Float) {
val max = if (currentPage == minPage) 0f else 1f
val min = if (currentPage == maxPage) 0f else -1f
_currentPageOffset.snapTo(offset.coerceIn(min, max))
}
suspend fun fling(velocity: Float) {
if (velocity < 0 && currentPage == maxPage) return
if (velocity > 0 && currentPage == minPage) return
// 根据 fling 的方向滑动到下一页或上一页
_currentPageOffset.animateTo(velocity)
selectPage()
}
override fun toString(): String = "PagerState{minPage=$minPage, maxPage=$maxPage, " +
"currentPage=$currentPage, currentPageOffset=$currentPageOffset}"
}
@Immutable
private data class PageData(val page: Int) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any? = this@PageData
}
private val Measurable.page: Int
get() = (parentData as? PageData)?.page ?: error("no PageData for measurable $this")
@Composable
fun Pager(
modifier: Modifier = Modifier,
state: PagerState,
orientation: Orientation = Orientation.Horizontal,
offscreenLimit: Int = 2,
horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, // 新增水平对齐参数
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, // 新增垂直对齐参数
content: @Composable PagerScope.() -> Unit
) {
var pageSize by remember { mutableStateOf(0) }
val coroutineScope = rememberCoroutineScope()
Layout(
content = {
// 根据 offscreenLimit 计算页面范围
val minPage = maxOf(state.currentPage - offscreenLimit, state.minPage)
val maxPage = minOf(state.currentPage + offscreenLimit, state.maxPage)
for (page in minPage..maxPage) {
val pageData = PageData(page)
val scope = PagerScope(state, page)
key(pageData) {
Column(modifier = pageData) {
scope.content()
}
}
}
},
modifier = modifier.draggable(
orientation = orientation,
onDragStarted = {
state.selectionState = PagerState.SelectionState.Undecided
},
onDragStopped = { velocity ->
coroutineScope.launch {
// 根据速度判断是否滑动到下一页
val threshold = 1000f // 速度阈值,可调整
if (velocity > threshold) {
state.fling(1f) // 向右滑动
} else if (velocity < -threshold) {
state.fling(-1f) // 向左滑动
} else {
state.fling(0f) // 保持当前页
}
}
},
state = rememberDraggableState { dy ->
coroutineScope.launch {
with(state) {
val pos = pageSize * currentPageOffset
val max = if (currentPage == minPage) 0 else pageSize
val min = if (currentPage == maxPage) 0 else -pageSize
// 直接将手指的位移应用到 currentPageOffset
val newPos = (pos + dy).coerceIn(min.toFloat(), max.toFloat())
snapToOffset(newPos / pageSize)
}
}
},
)
) { measurables, constraints ->
layout(constraints.maxWidth, constraints.maxHeight) {
val currentPage = state.currentPage
val offset = state.currentPageOffset
val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
measurables.forEach { measurable ->
val placeable = measurable.measure(childConstraints)
val page = measurable.page
// 根据对齐参数计算 x 和 y 位置
val xPosition = when (horizontalAlignment) {
Alignment.Start -> 0
Alignment.CenterHorizontally -> (constraints.maxWidth - placeable.width) / 2
Alignment.End -> constraints.maxWidth - placeable.width
else -> 0
}
val yPosition = when (verticalAlignment) {
Alignment.Top -> 0
Alignment.CenterVertically -> (constraints.maxHeight - placeable.height) / 2
Alignment.Bottom -> constraints.maxHeight - placeable.height
else -> 0
}
if (currentPage == page) { // 只在当前页面设置 pageSize避免不必要的设置
pageSize = if (orientation == Orientation.Horizontal) {
placeable.width
} else {
placeable.height
}
}
val isVisible = abs(page - (currentPage - offset)) <= 1
if (isVisible) {
// 修正 x 的计算
val xOffset = if (orientation == Orientation.Horizontal) {
((page - currentPage) * pageSize + offset * pageSize).roundToInt()
} else {
0
}
// 使用 placeRelative 进行放置
placeable.placeRelative(
x = xPosition + xOffset,
y = yPosition + if (orientation == Orientation.Vertical) ((page - (currentPage - offset)) * placeable.height).roundToInt() else 0
)
}
}
}
}
}
class PagerScope(
private val state: PagerState,
val page: Int
) {
val currentPage: Int
get() = state.currentPage
val currentPageOffset: Float
get() = state.currentPageOffset
val selectionState: PagerState.SelectionState
get() = state.selectionState
}

View File

@@ -0,0 +1,27 @@
package com.aiosman.riderpro
import android.app.Activity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import com.aiosman.riderpro.ShortViewCompose
import com.aiosman.riderpro.ui.theme.RiderProTheme
val videoUrls = listOf(
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/WeAreGoingOnBullrun.mp4"
)
@Composable
fun ShortVideo() {
RiderProTheme {
Surface(color = MaterialTheme.colorScheme.background) {
ShortViewCompose(
videoItemsUrl = videoUrls,
clickItemPosition = 0
)
}
}
}

View File

@@ -0,0 +1,180 @@
package com.aiosman.riderpro
import android.app.Activity
import android.net.Uri
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
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.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SimpleExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun ShortViewCompose(
videoItemsUrl:List<String>,
clickItemPosition:Int = 0,
videoHeader:@Composable () -> Unit = {},
videoBottom:@Composable () -> Unit = {}
) {
val pagerState: PagerState = run {
remember {
PagerState(clickItemPosition, 0, videoItemsUrl.size - 1)
}
}
val initialLayout= remember {
mutableStateOf(true)
}
val pauseIconVisibleState = remember {
mutableStateOf(false)
}
Pager(
state = pagerState,
orientation = Orientation.Vertical,
offscreenLimit = 1
) {
pauseIconVisibleState.value=false
SingleVideoItemContent(videoItemsUrl[page],
pagerState,
page,
initialLayout,
pauseIconVisibleState,
videoHeader,
videoBottom)
}
LaunchedEffect(clickItemPosition){
delay(300)
initialLayout.value=false
}
}
@Composable
private fun SingleVideoItemContent(
videoUrl: String,
pagerState: PagerState,
pager: Int,
initialLayout: MutableState<Boolean>,
pauseIconVisibleState: MutableState<Boolean>,
VideoHeader: @Composable() () -> Unit,
VideoBottom: @Composable() () -> Unit,
) {
Box(modifier = Modifier.fillMaxSize()){
VideoPlayer(videoUrl,pagerState,pager,pauseIconVisibleState)
VideoHeader.invoke()
Box(modifier = Modifier.align(Alignment.BottomStart)){
VideoBottom.invoke()
}
if (initialLayout.value) {
Box(modifier = Modifier
.fillMaxSize()
.background(color = Color.Black))
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun VideoPlayer(
videoUrl: String,
pagerState: PagerState,
pager: Int,
pauseIconVisibleState: MutableState<Boolean>,
) {
val context = LocalContext.current
val scope= rememberCoroutineScope()
val exoPlayer = remember {
SimpleExoPlayer.Builder(context)
.build()
.apply {
val dataSourceFactory: DataSource.Factory = DefaultDataSourceFactory(
context,
Util.getUserAgent(context, context.packageName)
)
val source = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(Uri.parse(videoUrl)))
this.prepare(source)
}
}
if (pager == pagerState.currentPage) {
exoPlayer.playWhenReady = true
exoPlayer.play()
} else {
exoPlayer.pause()
}
exoPlayer.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
exoPlayer.repeatMode = Player.REPEAT_MODE_ONE
DisposableEffect(
Box(modifier = Modifier.fillMaxSize()){
AndroidView(factory = {
PlayerView(context).apply {
hideController()
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
},modifier = Modifier.noRippleClickable {
pauseIconVisibleState.value=true
exoPlayer.pause()
scope.launch {
delay(500)
if (exoPlayer.isPlaying) {
exoPlayer.pause()
} else {
pauseIconVisibleState.value=false
exoPlayer.play()
}
}
})
if (pauseIconVisibleState.value)
Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null,
modifier = Modifier
.align(Alignment.Center)
.size(80.dp))
}
) {
onDispose {
exoPlayer.release()
}
}
}

View File

@@ -0,0 +1,100 @@
package com.aiosman.riderpro
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.res.Resources
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.RelativeLayout
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
//import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
private const val COLOR_TRANSPARENT = 0
@SuppressLint("ObsoleteSdkInt")
@JvmOverloads
fun Activity.immersive(@ColorInt color: Int = COLOR_TRANSPARENT, darkMode: Boolean? = null) {
when {
Build.VERSION.SDK_INT >= 21 -> {
when (color) {
COLOR_TRANSPARENT -> {
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
var systemUiVisibility = window.decorView.systemUiVisibility
systemUiVisibility = systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
systemUiVisibility = systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
window.decorView.systemUiVisibility = systemUiVisibility
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.statusBarColor = color
}
else -> {
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
var systemUiVisibility = window.decorView.systemUiVisibility
systemUiVisibility = systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
systemUiVisibility = systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_STABLE
window.decorView.systemUiVisibility = systemUiVisibility
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.statusBarColor = color
}
}
}
Build.VERSION.SDK_INT >= 19 -> {
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
if (color != COLOR_TRANSPARENT) {
setTranslucentView(window.decorView as ViewGroup, color)
}
}
}
if (darkMode != null) {
darkMode(darkMode)
}
}
@JvmOverloads
fun Activity.darkMode(darkMode: Boolean = true) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
var systemUiVisibility = window.decorView.systemUiVisibility
systemUiVisibility = if (darkMode) {
systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
} else {
systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
}
window.decorView.systemUiVisibility = systemUiVisibility
}
}
private fun Context.setTranslucentView(container: ViewGroup, color: Int) {
if (Build.VERSION.SDK_INT >= 19) {
var simulateStatusBar: View? = container.findViewById(android.R.id.custom)
if (simulateStatusBar == null && color != 0) {
simulateStatusBar = View(container.context)
simulateStatusBar.id = android.R.id.custom
val lp = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, statusBarHeight)
container.addView(simulateStatusBar, lp)
}
simulateStatusBar?.setBackgroundColor(color)
}
}
val Context?.statusBarHeight: Int
get() {
this ?: return 0
var result = 24
val resId = resources.getIdentifier("status_bar_height", "dimen", "android")
result = if (resId > 0) {
resources.getDimensionPixelSize(resId)
} else {
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
result.toFloat(), Resources.getSystem().displayMetrics
).toInt()
}
return result
}

View File

@@ -1,4 +1,5 @@
[versions]
accompanistSystemuicontroller = "0.27.0"
agp = "8.4.0"
kotlin = "1.9.0"
coreKtx = "1.10.1"
@@ -8,12 +9,19 @@ espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2023.08.00"
mapsCompose = "4.3.3"
material3Android = "1.2.1"
media3Exoplayer = "1.3.1"
navigationCompose = "2.7.7"
pagingRuntime = "3.3.0"
activityKtx = "1.9.0"
[libraries]
accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3Exoplayer" }
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Exoplayer" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingRuntime" }
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingRuntime" }
@@ -21,7 +29,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version = "1.9.0" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
@@ -31,6 +39,8 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" }
maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "mapsCompose" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }