Files
Aether/app/src/main/java/com/acitelight/aether/view/VideoPlayer.kt
2025-09-07 13:09:25 +08:00

1245 lines
50 KiB
Kotlin

package com.acitelight.aether.view
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.media.AudioManager
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.lifecycle.viewmodel.compose.viewModel
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.navigation.NavHostController
import com.acitelight.aether.viewModel.VideoPlayerViewModel
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Brightness4
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.ThumbDown
import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import coil3.ImageLoader
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.Global
import com.acitelight.aether.ToggleFullScreen
import com.acitelight.aether.model.KeyImage
import com.acitelight.aether.model.Video
import kotlin.math.abs
fun formatTime(ms: Long): String {
if (ms <= 0) return "00:00:00"
val totalSeconds = ms / 1000
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d:%02d", hours, minutes, seconds)
}
@Composable
fun isLandscape(): Boolean {
val configuration = LocalConfiguration.current
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BiliStyleSlider(
modifier: Modifier = Modifier,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f
) {
val thumbRadius = 6.dp
val trackHeight = 3.dp
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
modifier = modifier,
colors = SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF), // B站粉色
activeTrackColor = Color(0xFFFF6699),
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
),
track = { sliderPositions ->
Box(
Modifier
.height(trackHeight)
.fillMaxWidth()
.background(Color.LightGray.copy(alpha = 0.3f), RoundedCornerShape(50))
) {
Box(
Modifier
.align(Alignment.CenterStart)
.fillMaxWidth(value)
.fillMaxHeight()
.background(Color(0xFFFF6699), RoundedCornerShape(50))
)
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BiliMiniSlider(
modifier: Modifier = Modifier,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f
) {
val thumbRadius = 6.dp
val trackHeight = 3.dp
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
modifier = modifier,
colors = SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF), // B站粉色
activeTrackColor = Color(0xFFFF6699),
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
),
thumb = {
},
track = { sliderPositions ->
Box(
Modifier
.height(trackHeight)
.fillMaxWidth()
.background(Color.LightGray.copy(alpha = 0.3f), RoundedCornerShape(50))
) {
Box(
Modifier
.align(Alignment.CenterStart)
.fillMaxWidth(value)
.fillMaxHeight()
.background(Color(0xFFFF6699), RoundedCornerShape(50))
)
}
}
)
}
@Composable
fun VideoPlayer(
videoPlayerViewModel: VideoPlayerViewModel = viewModel(),
videoId: String,
navController: NavHostController
) {
videoPlayerViewModel.Init(videoId)
if(videoPlayerViewModel.startPlaying)
{
if (isLandscape()) {
VideoPlayerLandscape(videoPlayerViewModel)
}
else
{
VideoPlayerPortal(videoPlayerViewModel, navController)
}
}
}
@Composable
fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel, cover: Float)
{
val exoPlayer: ExoPlayer = videoPlayerViewModel._player!!;
val context = LocalContext.current
val activity = context as? Activity
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
var volFactor by remember { mutableFloatStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume.toFloat()) }
fun setVolume(value: Int) {
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
value.coerceIn(0, maxVolume),
AudioManager.FLAG_PLAY_SOUND
)
}
Box(modifier)
{
AndroidView(
factory = {
PlayerView(
it
).apply {
player = exoPlayer
useController = false
}
},
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
if(offset.x < size.width / 2)
{
videoPlayerViewModel.draggingPurpose = -1;
}else{
videoPlayerViewModel.draggingPurpose = -2;
}
},
onDragEnd = {
if (videoPlayerViewModel.isPlaying && videoPlayerViewModel.draggingPurpose == 0)
exoPlayer.play()
videoPlayerViewModel.draggingPurpose = -1;
},
onDrag = { change, dragAmount ->
if(abs(dragAmount.x) > abs(dragAmount.y) &&
(videoPlayerViewModel.draggingPurpose == -1 || videoPlayerViewModel.draggingPurpose == -2))
{
videoPlayerViewModel.draggingPurpose = 0
videoPlayerViewModel.planeVisibility = true
exoPlayer.pause()
}
else if(videoPlayerViewModel.draggingPurpose == -1) videoPlayerViewModel.draggingPurpose = 1
else if(videoPlayerViewModel.draggingPurpose == -2) videoPlayerViewModel.draggingPurpose = 2
if(videoPlayerViewModel.draggingPurpose == 0)
{
exoPlayer.seekTo((exoPlayer.currentPosition + dragAmount.x * 200.0f).toLong())
videoPlayerViewModel.playProcess = exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat()
}else if(videoPlayerViewModel.draggingPurpose == 2)
{
val cu = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
volFactor = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume.toFloat()
if(dragAmount.y < 0)
setVolume( cu + 1);
else if(dragAmount.y > 0)
setVolume( cu - 1);
}else if(videoPlayerViewModel.draggingPurpose == 1)
{
videoPlayerViewModel.brit = (videoPlayerViewModel.brit - dragAmount.y * 0.002f).coerceIn(0f, 1f);
activity?.window?.attributes = activity.window.attributes.apply {
screenBrightness = videoPlayerViewModel.brit.coerceIn(0f, 1f)
}
activity?.window?.setAttributes(activity.window.attributes)
}
}
)
}
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
},
onTap = {
videoPlayerViewModel.planeVisibility =
!videoPlayerViewModel.planeVisibility
},
onLongPress = {
videoPlayerViewModel.isLongPressing = true
exoPlayer.playbackParameters = exoPlayer.playbackParameters
.withSpeed(3.0f)
},
onPress = { offset ->
val pressResult = tryAwaitRelease()
if (pressResult && videoPlayerViewModel.isLongPressing) {
videoPlayerViewModel.isLongPressing = false
exoPlayer.playbackParameters = exoPlayer.playbackParameters
.withSpeed(1.0f)
}
},
)
}
)
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.isLongPressing,
enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }),
modifier = Modifier
.align(Alignment.TopCenter)
)
{
Box(modifier = Modifier.align(Alignment.TopCenter).padding(top = 24.dp).background(Color(0x44000000), RoundedCornerShape(18)))
{
Row{
Icon(
imageVector = Icons.Filled.FastForward,
contentDescription = "Fast Forward",
tint = Color.White,
modifier = Modifier.size(36.dp).padding(4.dp).align(Alignment.CenterVertically)
)
Text(
text = "3X Speed...",
modifier = Modifier.padding(4.dp).align(Alignment.CenterVertically),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFFFFFF)
)
}
}
}
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.draggingPurpose == 0,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
)
{
Text(
text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${
formatTime(
(exoPlayer.duration).toLong()
)
}",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp),
fontSize = 18.sp
)
}
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.draggingPurpose == 2,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
)
{
Row(Modifier.background(Color(0x88000000), RoundedCornerShape(18)).width(200.dp))
{
Icon(
imageVector = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = "Vol",
tint = Color.White,
modifier = Modifier.size(48.dp).padding(8.dp)
.align(Alignment.CenterVertically)
)
BiliMiniSlider(
value = volFactor,
onValueChange = {},
modifier = Modifier
.height(4.dp)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically)
)
}
}
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.draggingPurpose == 1,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
)
{
Row(Modifier.background(Color(0x88000000), RoundedCornerShape(18)).width(200.dp))
{
Icon(
imageVector = Icons.Default.Brightness4,
contentDescription = "Brightness",
tint = Color.White,
modifier = Modifier.size(48.dp).padding(8.dp)
.align(Alignment.CenterVertically)
)
BiliMiniSlider(
value = videoPlayerViewModel.brit,
onValueChange = {},
modifier = Modifier
.height(4.dp)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically)
)
}
}
if(cover > 0.0f)
Spacer(Modifier.background(Color(0x00FF6699 - 0x00222222 + ((0x000000FF * cover).toLong() shl 24) )).fillMaxSize())
androidx.compose.animation.AnimatedVisibility(
visible = !videoPlayerViewModel.planeVisibility,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter)
) {
BiliMiniSlider(
value = videoPlayerViewModel.playProcess,
onValueChange = {},
modifier = Modifier
.height(4.dp)
.align(Alignment.BottomCenter)
)
}
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.planeVisibility,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).height(42.dp)
)
{
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter).background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.4f),
)
)),
horizontalArrangement = Arrangement.SpaceBetween,
) {
IconButton(
onClick = {
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
},
Modifier
.size(36.dp)
.align(Alignment.CenterVertically)
) {
Icon(
imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
BiliStyleSlider(
value = videoPlayerViewModel.playProcess,
onValueChange = { value ->
exoPlayer.seekTo((exoPlayer.duration * value).toLong())
},
modifier = Modifier
.height(8.dp)
.align(Alignment.CenterVertically)
.weight(1f)
)
Text(
text = formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong()),
maxLines = 1,
fontSize = 12.sp,
color = Color(0xFFFFFFFF),
fontWeight = FontWeight.Bold,
modifier = Modifier
.width(80.dp)
.align(Alignment.CenterVertically)
.padding(start = 12.dp)
)
IconButton(
onClick = {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
},
Modifier
.size(36.dp)
.align(Alignment.CenterVertically)
) {
Icon(
imageVector = Icons.Default.Fullscreen,
contentDescription = "Fullscreen",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
}
}
}
}
@Composable
fun VideoPlayerPortal(videoPlayerViewModel: VideoPlayerViewModel, navController: NavHostController)
{
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp;
val minHeight = 42.dp
var coverAlpha by remember{ mutableFloatStateOf(0.0f) }
var maxHeight = remember { screenHeight * 0.65f }
var posed = remember { false }
val dens = LocalDensity.current
val listState = rememberLazyListState()
var playerHeight by remember { mutableStateOf(screenHeight * 0.65f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val deltaY = available.y // px
val deltaDp = with(dens) { deltaY.toDp() }
val r = if (deltaY < 0 && playerHeight > minHeight) {
val newHeight = (playerHeight + deltaDp).coerceIn(minHeight, maxHeight)
val consumedDp = newHeight - playerHeight
playerHeight = newHeight
val consumedPx = with(dens) { consumedDp.toPx() }
Offset(0f, consumedPx)
} else if(deltaY > 0 && playerHeight < maxHeight && listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0) {
val newHeight = (playerHeight + deltaDp).coerceIn(minHeight, maxHeight)
val consumedDp = newHeight - playerHeight
playerHeight = newHeight
val consumedPx = with(dens) { consumedDp.toPx() }
Offset(0f, consumedPx)
} else {
Offset.Zero
}
val dh = playerHeight - minHeight;
coverAlpha = (if(dh > 10.dp)
0f
else
(10.dp.value - dh.value) / 10.0f)
return r
}
}
}
ToggleFullScreen(false)
Column(Modifier.nestedScroll(nestedScrollConnection).fillMaxHeight())
{
PortalCorePlayer(
Modifier
.padding(top = 42.dp)
.heightIn(max = playerHeight)
.onGloballyPositioned { layoutCoordinates ->
if(!posed && videoPlayerViewModel.renderedFirst)
{
maxHeight = with(dens) {layoutCoordinates.size.height.toDp()}
playerHeight = maxHeight
posed = true
}
},
videoPlayerViewModel = videoPlayerViewModel, coverAlpha)
Row()
{
TabRow (
selectedTabIndex = videoPlayerViewModel.tabIndex,
modifier = Modifier.height(38.dp).fillMaxWidth(0.6f)
) {
Tab(
selected = videoPlayerViewModel.tabIndex == 0,
onClick = { videoPlayerViewModel.tabIndex = 0 },
text = { Text(text = "Introduction", maxLines = 1) },
modifier = Modifier.height(38.dp)
)
Tab(
selected = videoPlayerViewModel.tabIndex == 1,
onClick = { videoPlayerViewModel.tabIndex = 1 },
text = { Text(text = "Comment", maxLines = 1) },
modifier = Modifier.height(38.dp)
)
}
}
LazyColumn(state = listState, modifier = Modifier.fillMaxWidth()) {
item{
HorizontalDivider(Modifier, 2.dp, DividerDefaults.color)
Text(
modifier = Modifier.align(Alignment.Start).padding(horizontal = 12.dp).padding(top = 12.dp),
text = videoPlayerViewModel.video?.video?.name ?: "",
fontSize = 16.sp,
maxLines = 2,
fontWeight = FontWeight.Bold,
)
Row(Modifier.align(Alignment.Start).padding(horizontal = 4.dp).alpha(0.5f)) {
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = videoPlayerViewModel.video?.klass ?: "",
fontSize = 14.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = formatTime(videoPlayerViewModel.video?.video?.duration ?: 0),
fontSize = 14.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color)
SocialPanel(Modifier.align(Alignment.CenterHorizontally).fillMaxWidth(), videoPlayerViewModel = videoPlayerViewModel)
HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color)
HorizontalGallery(videoPlayerViewModel)
HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color)
for(i in Global.sameClassVideos ?: listOf())
{
if(i.id == videoPlayerViewModel.video?.id) continue
MiniVideoCard(
modifier = Modifier
.padding(horizontal = 12.dp),
i,
{
videoPlayerViewModel.isPlaying = false
videoPlayerViewModel._player?.pause()
val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }"
navController.navigate(route)
}, videoPlayerViewModel.imageLoader!!)
HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color)
}
}
}
}
}
@Composable
fun SocialPanel(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel)
{
Row(
modifier,
horizontalArrangement = Arrangement.Center
)
{
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
IconButton(
onClick = { },
modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Filled.ThumbUp,
contentDescription = "ThumbUp",
tint = Color.Gray
)
}
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = videoPlayerViewModel.thumbUp.toString(),
fontSize = 12.sp,
maxLines = 1,
fontWeight = FontWeight.Bold)
}
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
IconButton(
onClick = { },
modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Filled.ThumbDown,
contentDescription = "ThumbDown",
tint = Color.Gray
)
}
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = videoPlayerViewModel.thumbDown.toString(),
fontSize = 12.sp,
maxLines = 1,
fontWeight = FontWeight.Bold)
}
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
IconButton(
onClick = { videoPlayerViewModel.star = !videoPlayerViewModel.star },
modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Filled.Star,
contentDescription = "Star",
tint = if(videoPlayerViewModel.star) Color(0xFFFF6699) else Color.Gray
)
}
}
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
IconButton(
onClick = { },
modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Filled.Share,
contentDescription = "Forward",
tint = Color.Gray
)
}
}
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
IconButton(
onClick = { },
modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Filled.Info,
contentDescription = "Detail",
tint = Color.Gray
)
}
}
}
}
@Composable
fun HorizontalGallery(videoPlayerViewModel: VideoPlayerViewModel)
{
LazyRow(
modifier = Modifier.fillMaxWidth().height(120.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 24.dp)
) {
items(videoPlayerViewModel.video?.getGallery() ?: listOf()) { it ->
SingleImageItem(img = it, videoPlayerViewModel.imageLoader!!)
}
}
}
@Composable
fun SingleImageItem(img: KeyImage, imageLoader: ImageLoader) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(img.url)
.memoryCacheKey(img.key)
.diskCacheKey(img.key)
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop,
imageLoader = imageLoader
)
}
@Composable
fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel)
{
val context = LocalContext.current
val activity = context as? Activity
val exoPlayer: ExoPlayer = videoPlayerViewModel._player!!;
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
var volFactor by remember { mutableFloatStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume.toFloat()) }
fun setVolume(value: Int) {
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
value.coerceIn(0, maxVolume),
AudioManager.FLAG_PLAY_SOUND
)
}
BackHandler {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
ToggleFullScreen(true)
Box(Modifier.fillMaxSize())
{
Box(
modifier = Modifier
.background(Color.Black).align(Alignment.Center)
)
{
AndroidView(
factory = {
PlayerView(
it
).apply {
player = exoPlayer
useController = false
}
},
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
if(offset.x < size.width / 2)
{
videoPlayerViewModel.draggingPurpose = -1;
}else{
videoPlayerViewModel.draggingPurpose = -2;
}
},
onDragEnd = {
if (videoPlayerViewModel.isPlaying && videoPlayerViewModel.draggingPurpose == 0)
exoPlayer.play()
videoPlayerViewModel.draggingPurpose = -1;
},
onDrag = { change, dragAmount ->
if(abs(dragAmount.x) > abs(dragAmount.y) &&
(videoPlayerViewModel.draggingPurpose == -1 || videoPlayerViewModel.draggingPurpose == -2))
{
videoPlayerViewModel.draggingPurpose = 0
videoPlayerViewModel.planeVisibility = true
exoPlayer.pause()
}
else if(videoPlayerViewModel.draggingPurpose == -1) videoPlayerViewModel.draggingPurpose = 1
else if(videoPlayerViewModel.draggingPurpose == -2) videoPlayerViewModel.draggingPurpose = 2
if(videoPlayerViewModel.draggingPurpose == 0)
{
exoPlayer.seekTo((exoPlayer.currentPosition + dragAmount.x * 200.0f).toLong())
videoPlayerViewModel.playProcess = exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat()
}else if(videoPlayerViewModel.draggingPurpose == 2)
{
val cu = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
volFactor = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume.toFloat()
if(dragAmount.y < 0)
setVolume( cu + 1);
else if(dragAmount.y > 0)
setVolume( cu - 1);
}else if(videoPlayerViewModel.draggingPurpose == 1)
{
videoPlayerViewModel.brit = (videoPlayerViewModel.brit - dragAmount.y * 0.002f).coerceIn(0f, 1f);
activity?.window?.attributes = activity.window.attributes.apply {
screenBrightness = videoPlayerViewModel.brit.coerceIn(0f, 1f)
}
activity?.window?.setAttributes(activity.window.attributes)
}
}
)
}
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
},
onTap = {
videoPlayerViewModel.planeVisibility =
!videoPlayerViewModel.planeVisibility
},
onLongPress = {
videoPlayerViewModel.isLongPressing = true
exoPlayer.playbackParameters = exoPlayer.playbackParameters
.withSpeed(3.0f)
},
onPress = { offset ->
val pressResult = tryAwaitRelease()
if (pressResult && videoPlayerViewModel.isLongPressing) {
videoPlayerViewModel.isLongPressing = false
exoPlayer.playbackParameters = exoPlayer.playbackParameters
.withSpeed(1.0f)
}
},
)
}
)
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.draggingPurpose == 0,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
)
{
Text(
text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${
formatTime(
(exoPlayer.duration).toLong()
)
}",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp),
fontSize = 18.sp
)
}
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.draggingPurpose == 2,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
)
{
Row(Modifier.background(Color(0x88000000), RoundedCornerShape(18)).width(200.dp))
{
Icon(
imageVector = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = "Vol",
tint = Color.White,
modifier = Modifier.size(48.dp).padding(8.dp)
.align(Alignment.CenterVertically)
)
BiliMiniSlider(
value = volFactor,
onValueChange = {},
modifier = Modifier
.height(4.dp)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically)
)
}
}
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.draggingPurpose == 1,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
)
{
Row(Modifier.background(Color(0x88000000), RoundedCornerShape(18)).width(200.dp))
{
Icon(
imageVector = Icons.Default.Brightness4,
contentDescription = "Brightness",
tint = Color.White,
modifier = Modifier.size(48.dp).padding(8.dp)
.align(Alignment.CenterVertically)
)
BiliMiniSlider(
value = videoPlayerViewModel.brit,
onValueChange = {},
modifier = Modifier
.height(4.dp)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically)
)
}
}
AnimatedVisibility(
visible = videoPlayerViewModel.isLongPressing,
enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }),
modifier = Modifier
.align(Alignment.TopCenter)
)
{
Box(modifier = Modifier.align(Alignment.TopCenter).padding(top = 24.dp).background(Color(0x44000000), RoundedCornerShape(18)))
{
Row{
Icon(
imageVector = Icons.Filled.FastForward,
contentDescription = "Fast Forward",
tint = Color.White,
modifier = Modifier.size(36.dp).padding(4.dp).align(Alignment.CenterVertically)
)
Text(
text = "3X Speed...",
modifier = Modifier.padding(4.dp).align(Alignment.CenterVertically),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFFFFFF)
)
}
}
}
AnimatedVisibility(
visible = videoPlayerViewModel.planeVisibility,
enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }),
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
)
{
Row(Modifier
.align(Alignment.TopStart)
.padding(horizontal = 32.dp).background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.4f),
Color.Transparent,
)
)))
{
IconButton(
onClick = {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
},
modifier = Modifier.size(36.dp).align(Alignment.CenterVertically)
) {
Icon(
modifier = Modifier.size(36.dp),
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
Text(
text = "${videoPlayerViewModel.video?.video?.name}",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 12.dp).align(Alignment.CenterVertically),
fontSize = 18.sp
)
}
}
AnimatedVisibility(
visible = videoPlayerViewModel.planeVisibility,
enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
)
{
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background( brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.4f)
)
))
.padding(horizontal = 36.dp)
) {
Text(
text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${
formatTime(
(exoPlayer.duration).toLong()
)
}",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp),
fontSize = 12.sp
)
BiliStyleSlider(
value = videoPlayerViewModel.playProcess,
onValueChange = { value ->
exoPlayer.seekTo((exoPlayer.duration * value).toLong())
},
modifier = Modifier.height(16.dp).fillMaxWidth().padding(bottom = 8.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
.align(Alignment.Start),
horizontalArrangement = Arrangement.SpaceBetween,
) {
IconButton(
onClick = {
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
},
Modifier.size(42.dp)
) {
Icon(
imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
tint = Color.White,
modifier = Modifier.size(42.dp)
)
}
}
}
}
}
}
}
@Composable
fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit, imageLoader: ImageLoader)
{
var isImageLoaded by remember { mutableStateOf(false) }
Card(
modifier = modifier.height(80.dp).fillMaxWidth(),
colors = CardColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
disabledContentColor = Color.Transparent,
disabledContainerColor = Color.Transparent
),
onClick = onClick
)
{
Row()
{
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(video.getCover())
.memoryCacheKey("${video.klass}/${video.id}/cover")
.diskCacheKey("${video.klass}/${video.id}/cover")
.listener(
onStart = { },
onSuccess = { _, _ -> isImageLoaded = true },
onError = { _, _ -> }
)
.build(),
contentDescription = null,
modifier = Modifier
.width(128.dp).fillMaxHeight()
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop,
imageLoader = imageLoader
)
Column (
modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight().fillMaxWidth().align(Alignment.CenterVertically),
verticalArrangement = Arrangement.Center
)
{
Text(
modifier = Modifier,
text = video.video.name,
fontSize = 14.sp,
maxLines = 2,
fontWeight = FontWeight.Bold,
)
Spacer(modifier.weight(1f))
Text(
modifier = Modifier.height(16.dp),
text = video.klass,
fontSize = 8.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
Text(
modifier = Modifier.height(16.dp),
text = formatTime(video.video.duration),
fontSize = 8.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
}
}
}
}