diff --git a/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/VideoLibraryScreen.kt b/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/VideoLibraryScreen.kt index c875c63..ef72226 100644 --- a/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/VideoLibraryScreen.kt +++ b/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/VideoLibraryScreen.kt @@ -1,48 +1,21 @@ package com.wei.picquest.feature.video.videolibrary -import android.content.Context -import android.graphics.Rect import androidx.compose.foundation.ExperimentalFoundationApi -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.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel @@ -50,10 +23,7 @@ import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import androidx.navigation.NavController @@ -61,21 +31,17 @@ import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems -import coil.compose.AsyncImage -import coil.request.ImageRequest import com.wei.picquest.core.designsystem.component.ThemePreviews -import com.wei.picquest.core.designsystem.component.coilImagePainter -import com.wei.picquest.core.designsystem.icon.PqIcons import com.wei.picquest.core.designsystem.theme.PqTheme -import com.wei.picquest.core.designsystem.theme.SPACING_MEDIUM -import com.wei.picquest.core.designsystem.theme.SPACING_SMALL import com.wei.picquest.core.model.data.VideoDetail import com.wei.picquest.core.model.data.VideoDetailSize import com.wei.picquest.core.model.data.VideoStreams -import com.wei.picquest.core.pip.enterPictureInPicture import com.wei.picquest.core.pip.isInPictureInPictureMode -import com.wei.picquest.core.pip.updatedPipParams -import com.wei.picquest.feature.video.R +import com.wei.picquest.feature.video.videolibrary.component.NoDataMessage +import com.wei.picquest.feature.video.videolibrary.component.PageLoader +import com.wei.picquest.feature.video.videolibrary.component.PageLoaderError +import com.wei.picquest.feature.video.videolibrary.component.TopBarActions +import com.wei.picquest.feature.video.videolibrary.component.VideoPager import com.wei.picquest.feature.video.videolibrary.lifecycle.MediaPiPLifecycle import kotlinx.coroutines.flow.MutableStateFlow @@ -123,12 +89,44 @@ internal fun VideoLibraryRoute( countState.value }, ) + val isPlayerReady = remember { mutableStateOf(false) } + val exoPlayer = rememberExoPlayerInstance(isPlayerReady = isPlayerReady) - VideoLibraryScreen( - lazyPagingItems = lazyPagingItems, - pagerState = pagerState, - isInPiPMode = isInPiPMode, - onBackClick = navController::popBackStack, + MediaPiPLifecycle(exoPlayer = exoPlayer) + + LaunchedEffect(lazyPagingItems.loadState.refresh, pagerState.currentPage) { + if (shouldUpdatePlayerSource(lazyPagingItems, pagerState)) { + updateExoPlayerSource(exoPlayer, lazyPagingItems[pagerState.currentPage]) + } + } + + if (isInPiPMode) { + DefaultPictureInPictureContent(exoPlayer) + } else { + VideoLibraryScreen( + lazyPagingItems = lazyPagingItems, + pagerState = pagerState, + onBackClick = navController::popBackStack, + exoPlayer = exoPlayer, + isPlayerReady = isPlayerReady, + ) + } +} + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +internal fun DefaultPictureInPictureContent(exoPlayer: ExoPlayer) { + AndroidView( + factory = { + PlayerView(it).apply { + useController = false + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + player = exoPlayer + } + }, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), ) } @@ -137,148 +135,37 @@ internal fun VideoLibraryRoute( internal fun VideoLibraryScreen( lazyPagingItems: LazyPagingItems, pagerState: PagerState, - isInPiPMode: Boolean = false, + exoPlayer: ExoPlayer? = null, onBackClick: () -> Unit, withTopSpacer: Boolean = true, withBottomSpacer: Boolean = true, isPreview: Boolean = false, + isPlayerReady: MutableState, ) { Surface(modifier = Modifier.fillMaxSize()) { Box { VideoPager( lazyPagingItems = lazyPagingItems, pagerState = pagerState, + exoPlayer = exoPlayer, + isPlayerReady = isPlayerReady, isPreview = isPreview, ) PagingStateHandling(lazyPagingItems = lazyPagingItems) - if (!isInPiPMode) { - TopBarActions(onBackClick = onBackClick) - } + TopBarActions(onBackClick = onBackClick) } } } -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun VideoPager( - lazyPagingItems: LazyPagingItems, - pagerState: PagerState, - isPreview: Boolean, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - VerticalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { page -> - val videoDetail = lazyPagingItems[page] - - videoDetail?.let { - VideoPlayer( - videoDetail = videoDetail, - isCurrentPage = pagerState.currentPage == page, - isPreview = isPreview, - ) - } - } - } -} - -@Composable -fun TopBarActions( - onBackClick: () -> Unit, -) { - Column { - Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) - Row(modifier = Modifier.padding(SPACING_MEDIUM.dp)) { - BackButton(onBackClick = onBackClick) - Spacer(modifier = Modifier.weight(1f)) - } - } -} - -@Composable -fun BackButton( - onBackClick: () -> Unit, -) { - IconButton( - onClick = { onBackClick() }, - modifier = Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .semantics { contentDescription = "Search" }, - ) { - Icon( - imageVector = PqIcons.Search, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } -} - @androidx.annotation.OptIn(UnstableApi::class) @Composable -fun VideoPlayer( - videoDetail: VideoDetail, - isCurrentPage: Boolean, - isPreview: Boolean, -) { - if (isPreview) { - Box( - Modifier.background(MaterialTheme.colorScheme.error), - ) { - LoadingView(isPreview = true) - PiPButtonLayout( - onPipClick = {}, - ) - } - } else { - val context = LocalContext.current - val isInPiPMode = context.isInPictureInPictureMode - val isPlayerReady = remember { mutableStateOf(false) } - - Box( - Modifier.background(MaterialTheme.colorScheme.background), - ) { - if (isCurrentPage) { - PlayerViewContainer( - context = context, - videoDetail = videoDetail, - isPlayerReady = isPlayerReady, - ) - - if (!isInPiPMode) { - PiPButtonLayout(onPipClick = { - enterPictureInPicture( - context = context, - ) - }) - } - } - - if (!isPlayerReady.value || !isCurrentPage) { - val previewUrl = "https://i.vimeocdn.com/video/${videoDetail.pictureId}_295x166.jpg" - LoadingView(previewUrl = previewUrl) - } - } - } -} - -@androidx.annotation.OptIn(UnstableApi::class) -@Composable -fun rememberExoPlayer( - context: Context, - videoDetail: VideoDetail, +fun rememberExoPlayerInstance( isPlayerReady: MutableState, - playerViewBounds: MutableState, ): ExoPlayer { - val uri = videoDetail.videos.tiny.url.toUri() - val mediaItem = MediaItem.Builder().setUri(uri).setMediaId(videoDetail.id.toString()).build() - - return remember(videoDetail.id) { + val context = LocalContext.current + return remember { ExoPlayer.Builder(context).build().apply { playWhenReady = true videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT @@ -287,129 +174,31 @@ fun rememberExoPlayer( addListener(object : Player.Listener { override fun onPlaybackStateChanged(state: Int) { isPlayerReady.value = (state == Player.STATE_READY) - playerViewBounds.value?.let { bounds -> - calculateAndSetPiPParams(context, bounds, videoDetail) - } } }) - - val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(context) - val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(mediaItem) - - setMediaSource(mediaSource) - prepare() } } } -@androidx.annotation.OptIn(UnstableApi::class) -@Composable -fun PlayerViewContainer( - context: Context, - videoDetail: VideoDetail, - isPlayerReady: MutableState, -) { - val playerViewBounds = remember { mutableStateOf(null) } - val exoPlayer = rememberExoPlayer( - context = context, - videoDetail = videoDetail, - isPlayerReady = isPlayerReady, - playerViewBounds = playerViewBounds, - ) - - MediaPiPLifecycle(exoPlayer = exoPlayer) - - DisposableEffect(videoDetail.id) { - onDispose { exoPlayer.release() } - } - - AndroidView( - factory = { - PlayerView(it).apply { - useController = false - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT - player = exoPlayer - } - }, - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .onGloballyPositioned { layoutCoordinates -> - playerViewBounds.value = layoutCoordinates - .boundsInWindow() - .toAndroidGraphicsRect() - }, - ) -} - -@Composable -fun PiPButtonLayout(onPipClick: () -> Unit) { - Column { - Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) - Row(modifier = Modifier.padding(SPACING_MEDIUM.dp)) { - Spacer(modifier = Modifier.weight(1f)) - PiPButton( - onPipClick = onPipClick, - ) - } +fun updateExoPlayerSource(exoPlayer: ExoPlayer, videoDetail: VideoDetail?) { + videoDetail?.let { + val uri = it.videos.tiny.url.toUri() + val mediaItem = MediaItem.Builder().setUri(uri).build() + exoPlayer.setMediaItem(mediaItem) + exoPlayer.prepare() } } -@Composable -fun PiPButton( - onPipClick: () -> Unit, -) { - IconButton( - onClick = { onPipClick() }, - modifier = Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .semantics { contentDescription = "PictureInPicture" }, - ) { - Icon( - imageVector = PqIcons.PictureInPicture, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } -} - -fun androidx.compose.ui.geometry.Rect.toAndroidGraphicsRect(): android.graphics.Rect { - return android.graphics.Rect( - left.toInt(), - top.toInt(), - right.toInt(), - bottom.toInt(), - ) -} - -fun calculateAndSetPiPParams(context: Context, viewBounds: Rect, videoDetail: VideoDetail) { - val videoDetailWidth = videoDetail.videos.tiny.width - val videoDetailHeight = videoDetail.videos.tiny.height - val videoAspectRatio = videoDetailWidth.toFloat() / videoDetailHeight.toFloat() - - val finalWidth: Float - val finalHeight: Float - if (videoAspectRatio > viewBounds.width().toFloat() / viewBounds.height().toFloat()) { - finalWidth = viewBounds.width().toFloat() - finalHeight = finalWidth / videoAspectRatio - } else { - finalHeight = viewBounds.height().toFloat() - finalWidth = finalHeight * videoAspectRatio - } - - val offsetX = (viewBounds.width() - finalWidth) / 2 - val offsetY = (viewBounds.height() - finalHeight) / 2 - - val rect = Rect( - (viewBounds.left + offsetX).toInt(), - (viewBounds.top + offsetY).toInt(), - (viewBounds.left + offsetX + finalWidth).toInt(), - (viewBounds.top + offsetY + finalHeight).toInt(), - ) +@OptIn(ExperimentalFoundationApi::class) +fun shouldUpdatePlayerSource( + lazyPagingItems: LazyPagingItems, + pagerState: PagerState, +): Boolean { + val loadState = lazyPagingItems.loadState.refresh + val itemCount = lazyPagingItems.itemCount + val currentPage = pagerState.currentPage - updatedPipParams(context, rect) + return loadState is LoadState.NotLoading && itemCount > 0 && currentPage < itemCount } @Composable @@ -428,86 +217,6 @@ fun PagingStateHandling(lazyPagingItems: LazyPagingItems) { } } -@Composable -fun NoDataMessage() { - val noDataFound = stringResource(R.string.no_data_found) - Box( - modifier = Modifier - .fillMaxSize() - .semantics { - contentDescription = noDataFound - }, - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "(´・ω・`)", - style = MaterialTheme.typography.displayMedium, - ) - Spacer(modifier = Modifier.height(SPACING_SMALL.dp)) - Text( - text = noDataFound, - style = MaterialTheme.typography.bodyLarge, - ) - } - } -} - -@Composable -fun PageLoaderError(onClickRetry: () -> Unit) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize(), - ) { - OutlinedButton(onClick = onClickRetry) { - Text(text = stringResource(R.string.retry)) - } - } -} - -@Composable -fun PageLoader() { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize(), - ) { - CircularProgressIndicator() - } -} - -@Composable -private fun LoadingView( - previewUrl: String? = "", - isPreview: Boolean = false, -) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - if (isPreview) { - val resId = R.drawable.preview_images - val painter = coilImagePainter(resId, isPreview) - Image( - painter = painter, - contentDescription = "", - modifier = Modifier.fillMaxSize(), - ) - } else { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(previewUrl) - .crossfade(true) - .build(), - contentDescription = "", - modifier = Modifier.fillMaxSize(), - ) - } - CircularProgressIndicator(modifier = Modifier.size(30.dp)) - } -} - @OptIn(ExperimentalFoundationApi::class) @ThemePreviews @Composable @@ -520,16 +229,17 @@ fun VideoLibraryScreenPreview() { initialPageOffsetFraction = 0f, pageCount = { 1 }, ) + val isPlayerReady = remember { mutableStateOf(false) } PqTheme { VideoLibraryScreen( lazyPagingItems = lazyPagingItems, pagerState = pagerState, - isInPiPMode = false, onBackClick = {}, withTopSpacer = false, withBottomSpacer = false, isPreview = true, + isPlayerReady = isPlayerReady, ) } } diff --git a/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/LoadStateComponents.kt b/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/LoadStateComponents.kt new file mode 100644 index 0000000..dd56552 --- /dev/null +++ b/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/LoadStateComponents.kt @@ -0,0 +1,107 @@ +package com.wei.picquest.feature.video.videolibrary.component + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.wei.picquest.core.designsystem.component.coilImagePainter +import com.wei.picquest.core.designsystem.theme.SPACING_SMALL +import com.wei.picquest.feature.video.R + +@Composable +internal fun LoadingView( + previewUrl: String? = "", + isPreview: Boolean = false, +) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + if (isPreview) { + val resId = R.drawable.preview_images + val painter = coilImagePainter(resId, isPreview) + Image( + painter = painter, + contentDescription = "", + modifier = Modifier.fillMaxSize(), + ) + } else { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(previewUrl) + .crossfade(true) + .build(), + contentDescription = "", + modifier = Modifier.fillMaxSize(), + ) + } + CircularProgressIndicator(modifier = Modifier.size(30.dp)) + } +} + +@Composable +internal fun PageLoader() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + CircularProgressIndicator() + } +} + +@Composable +fun PageLoaderError(onClickRetry: () -> Unit) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + OutlinedButton(onClick = onClickRetry) { + Text(text = stringResource(R.string.retry)) + } + } +} + +@Composable +fun NoDataMessage() { + val noDataFound = stringResource(R.string.no_data_found) + Box( + modifier = Modifier + .fillMaxSize() + .semantics { + contentDescription = noDataFound + }, + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "(´・ω・`)", + style = MaterialTheme.typography.displayMedium, + ) + Spacer(modifier = Modifier.height(SPACING_SMALL.dp)) + Text( + text = noDataFound, + style = MaterialTheme.typography.bodyLarge, + ) + } + } +} diff --git a/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/PiPComponents.kt b/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/PiPComponents.kt new file mode 100644 index 0000000..0680288 --- /dev/null +++ b/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/PiPComponents.kt @@ -0,0 +1,54 @@ +package com.wei.picquest.feature.video.videolibrary.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.wei.picquest.core.designsystem.icon.PqIcons +import com.wei.picquest.core.designsystem.theme.SPACING_MEDIUM + +@Composable +fun PiPButtonLayout(onPipClick: () -> Unit) { + Column { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + Row(modifier = Modifier.padding(SPACING_MEDIUM.dp)) { + Spacer(modifier = Modifier.weight(1f)) + PiPButton( + onPipClick = onPipClick, + ) + } + } +} + +@Composable +fun PiPButton( + onPipClick: () -> Unit, +) { + IconButton( + onClick = { onPipClick() }, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .semantics { contentDescription = "PictureInPicture" }, + ) { + Icon( + imageVector = PqIcons.PictureInPicture, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } +} diff --git a/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/TopBarComponents.kt b/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/TopBarComponents.kt new file mode 100644 index 0000000..516e6c7 --- /dev/null +++ b/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/TopBarComponents.kt @@ -0,0 +1,54 @@ +package com.wei.picquest.feature.video.videolibrary.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.wei.picquest.core.designsystem.icon.PqIcons +import com.wei.picquest.core.designsystem.theme.SPACING_MEDIUM + +@Composable +fun TopBarActions( + onBackClick: () -> Unit, +) { + Column { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + Row(modifier = Modifier.padding(SPACING_MEDIUM.dp)) { + BackButton(onBackClick = onBackClick) + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Composable +fun BackButton( + onBackClick: () -> Unit, +) { + IconButton( + onClick = { onBackClick() }, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .semantics { contentDescription = "Search" }, + ) { + Icon( + imageVector = PqIcons.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } +} diff --git a/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/VideoPager.kt b/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/VideoPager.kt new file mode 100644 index 0000000..b17940e --- /dev/null +++ b/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/VideoPager.kt @@ -0,0 +1,45 @@ +package com.wei.picquest.feature.video.videolibrary.component + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.VerticalPager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.media3.exoplayer.ExoPlayer +import androidx.paging.compose.LazyPagingItems +import com.wei.picquest.core.model.data.VideoDetail + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun VideoPager( + lazyPagingItems: LazyPagingItems, + pagerState: PagerState, + exoPlayer: ExoPlayer?, + isPlayerReady: MutableState, + isPreview: Boolean, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + VerticalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + val videoDetail = lazyPagingItems[page] + + videoDetail?.let { + VideoPlayer( + videoDetail = videoDetail, + exoPlayer = exoPlayer, + isPlayerReady = isPlayerReady, + isCurrentPage = pagerState.currentPage == page, + isPreview = isPreview, + ) + } + } + } +} diff --git a/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/VideoPlayerComponents.kt b/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/VideoPlayerComponents.kt new file mode 100644 index 0000000..1582aba --- /dev/null +++ b/feature/video/src/main/java/com/wei/picquest/feature/video/videolibrary/component/VideoPlayerComponents.kt @@ -0,0 +1,139 @@ +package com.wei.picquest.feature.video.videolibrary.component + +import android.content.Context +import android.graphics.Rect +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import com.wei.picquest.core.model.data.VideoDetail +import com.wei.picquest.core.pip.enterPictureInPicture +import com.wei.picquest.core.pip.updatedPipParams + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun VideoPlayer( + videoDetail: VideoDetail, + exoPlayer: ExoPlayer?, + isPlayerReady: MutableState, + isCurrentPage: Boolean, + isPreview: Boolean, +) { + if (isPreview) { + Box( + Modifier.background(MaterialTheme.colorScheme.error), + ) { + LoadingView(isPreview = true) + PiPButtonLayout( + onPipClick = {}, + ) + } + } else { + val context = LocalContext.current + + Box( + Modifier.background(MaterialTheme.colorScheme.background), + ) { + if (isCurrentPage) { + exoPlayer?.let { + PlayerViewContainer( + context = context, + videoDetail = videoDetail, + exoPlayer = it, + ) + } + + PiPButtonLayout(onPipClick = { + enterPictureInPicture( + context = context, + ) + }) + } + + if (!isPlayerReady.value || !isCurrentPage) { + val previewUrl = "https://i.vimeocdn.com/video/${videoDetail.pictureId}_295x166.jpg" + LoadingView(previewUrl = previewUrl) + } + } + } +} + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun PlayerViewContainer( + context: Context, + videoDetail: VideoDetail, + exoPlayer: ExoPlayer, +) { + val playerViewBounds = remember { mutableStateOf(null) } + + AndroidView( + factory = { + PlayerView(it).apply { + useController = false + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + player = exoPlayer + } + }, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .onGloballyPositioned { layoutCoordinates -> + playerViewBounds.value = layoutCoordinates + .boundsInWindow() + .toAndroidGraphicsRect() + }, + ) + + playerViewBounds.value?.let { calculateAndSetPiPParams(context, it, videoDetail) } +} + +fun androidx.compose.ui.geometry.Rect.toAndroidGraphicsRect(): android.graphics.Rect { + return android.graphics.Rect( + left.toInt(), + top.toInt(), + right.toInt(), + bottom.toInt(), + ) +} + +fun calculateAndSetPiPParams(context: Context, viewBounds: Rect, videoDetail: VideoDetail) { + val videoDetailWidth = videoDetail.videos.tiny.width + val videoDetailHeight = videoDetail.videos.tiny.height + val videoAspectRatio = videoDetailWidth.toFloat() / videoDetailHeight.toFloat() + + val finalWidth: Float + val finalHeight: Float + if (videoAspectRatio > viewBounds.width().toFloat() / viewBounds.height().toFloat()) { + finalWidth = viewBounds.width().toFloat() + finalHeight = finalWidth / videoAspectRatio + } else { + finalHeight = viewBounds.height().toFloat() + finalWidth = finalHeight * videoAspectRatio + } + + val offsetX = (viewBounds.width() - finalWidth) / 2 + val offsetY = (viewBounds.height() - finalHeight) / 2 + + val rect = Rect( + (viewBounds.left + offsetX).toInt(), + (viewBounds.top + offsetY).toInt(), + (viewBounds.left + offsetX + finalWidth).toInt(), + (viewBounds.top + offsetY + finalHeight).toInt(), + ) + + updatedPipParams(context, rect) +}