|
4 | 4 | */ |
5 | 5 | package ch.srgssr.pillarbox.demo.ui.showcases.layouts |
6 | 6 |
|
7 | | -import androidx.compose.animation.core.Spring |
8 | | -import androidx.compose.animation.core.spring |
9 | | -import androidx.compose.foundation.layout.Arrangement |
| 7 | +import android.os.Build |
| 8 | +import androidx.compose.animation.animateColorAsState |
| 9 | +import androidx.compose.foundation.background |
10 | 10 | import androidx.compose.foundation.layout.Box |
11 | | -import androidx.compose.foundation.layout.Row |
| 11 | +import androidx.compose.foundation.layout.Column |
12 | 12 | import androidx.compose.foundation.layout.fillMaxHeight |
13 | 13 | import androidx.compose.foundation.layout.fillMaxSize |
14 | 14 | import androidx.compose.foundation.layout.fillMaxWidth |
15 | 15 | import androidx.compose.foundation.layout.padding |
16 | 16 | import androidx.compose.foundation.layout.size |
17 | | -import androidx.compose.foundation.pager.HorizontalPager |
18 | 17 | import androidx.compose.foundation.pager.PagerDefaults |
19 | 18 | import androidx.compose.foundation.pager.PagerSnapDistance |
| 19 | +import androidx.compose.foundation.pager.VerticalPager |
20 | 20 | import androidx.compose.foundation.pager.rememberPagerState |
| 21 | +import androidx.compose.foundation.shape.CircleShape |
| 22 | +import androidx.compose.material3.CircularProgressIndicator |
| 23 | +import androidx.compose.material3.LinearProgressIndicator |
21 | 24 | import androidx.compose.material3.MaterialTheme |
22 | | -import androidx.compose.material3.Text |
23 | 25 | import androidx.compose.runtime.Composable |
| 26 | +import androidx.compose.runtime.LaunchedEffect |
| 27 | +import androidx.compose.runtime.collectAsState |
| 28 | +import androidx.compose.runtime.derivedStateOf |
| 29 | +import androidx.compose.runtime.getValue |
| 30 | +import androidx.compose.runtime.movableContentOf |
| 31 | +import androidx.compose.runtime.mutableIntStateOf |
| 32 | +import androidx.compose.runtime.remember |
| 33 | +import androidx.compose.runtime.setValue |
24 | 34 | import androidx.compose.ui.Alignment |
25 | 35 | import androidx.compose.ui.Modifier |
26 | 36 | import androidx.compose.ui.draw.drawBehind |
27 | 37 | import androidx.compose.ui.graphics.Color |
| 38 | +import androidx.compose.ui.tooling.preview.Preview |
28 | 39 | import androidx.compose.ui.unit.dp |
29 | | -import androidx.lifecycle.compose.LifecycleStartEffect |
30 | 40 | import androidx.lifecycle.viewmodel.compose.viewModel |
| 41 | +import androidx.media3.common.Player |
| 42 | +import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme |
31 | 43 | import ch.srgssr.pillarbox.demo.ui.theme.paddings |
| 44 | +import ch.srgssr.pillarbox.player.currentPositionAsFlow |
| 45 | +import ch.srgssr.pillarbox.player.playbackStateAsFlow |
32 | 46 | import ch.srgssr.pillarbox.ui.ScaleMode |
33 | 47 | import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface |
34 | 48 | import ch.srgssr.pillarbox.ui.widget.player.SurfaceType |
| 49 | +import kotlinx.coroutines.delay |
| 50 | +import kotlinx.coroutines.flow.map |
| 51 | +import kotlin.time.Duration.Companion.milliseconds |
| 52 | +import kotlin.time.Duration.Companion.seconds |
35 | 53 |
|
36 | 54 | /** |
37 | | - * Optimized story trying to reproduce story-like TikTok or Instagram. |
38 | | - * |
39 | | - * Surface view may sometimes keep on screen. Maybe if we use TextView with PlayerView this strange behavior will disappear. |
| 55 | + * Optimized story-like layout. |
40 | 56 | */ |
41 | 57 | @Composable |
42 | 58 | fun OptimizedStory(storyViewModel: StoryViewModel = viewModel()) { |
43 | | - val pagerState = rememberPagerState { |
44 | | - storyViewModel.playlist.items.size |
| 59 | + val mediaItems = storyViewModel.mediaItems |
| 60 | + val pagerState = rememberPagerState { mediaItems.size } |
| 61 | + val settledPage by remember { derivedStateOf { pagerState.settledPage } } |
| 62 | + val currentPage by remember { derivedStateOf { pagerState.currentPage } } |
| 63 | + LaunchedEffect(settledPage) { |
| 64 | + storyViewModel.play(storyViewModel.getPlayer(settledPage)) |
| 65 | + } |
| 66 | + LaunchedEffect(currentPage) { |
| 67 | + storyViewModel.setCurrentPage(currentPage) |
45 | 68 | } |
46 | | - LifecycleStartEffect(pagerState) { |
47 | | - storyViewModel.getPlayerForPageNumber(pagerState.currentPage).play() |
48 | 69 |
|
49 | | - onStopOrDispose { |
50 | | - storyViewModel.pauseAllPlayer() |
| 70 | + val movablePlayerView = remember { |
| 71 | + (0 until storyViewModel.playerCount).map { index -> |
| 72 | + movableContentOf { |
| 73 | + val player = remember { storyViewModel.getPlayer(index) } |
| 74 | + PlayerView(player, modifier = Modifier.fillMaxSize()) |
| 75 | + } |
51 | 76 | } |
52 | 77 | } |
53 | 78 |
|
54 | | - val playlist = storyViewModel.playlist.items |
55 | 79 | Box(modifier = Modifier.fillMaxSize()) { |
56 | | - HorizontalPager( |
57 | | - beyondViewportPageCount = 0, |
58 | | - key = { page -> playlist[page].uri }, |
| 80 | + VerticalPager( |
59 | 81 | flingBehavior = PagerDefaults.flingBehavior( |
60 | 82 | state = pagerState, |
61 | 83 | pagerSnapDistance = PagerSnapDistance.atMost(0), |
62 | | - snapAnimationSpec = spring(stiffness = Spring.StiffnessHigh) |
63 | 84 | ), |
64 | | - pageSpacing = 1.dp, |
65 | | - state = pagerState |
| 85 | + beyondViewportPageCount = 0, |
| 86 | + state = pagerState, |
66 | 87 | ) { page -> |
67 | | - // When flinging -> may "load" more that 3 pages |
68 | | - val currentPage = pagerState.currentPage |
69 | | - val player = if (page == currentPage - 1 || page == currentPage + 1 || page == currentPage) { |
70 | | - val playerConfig = storyViewModel.getPlayerAndMediaItemIndexForPage(page) |
71 | | - val playerPage = storyViewModel.getPlayerFromIndex(playerConfig.first) |
72 | | - playerPage.playWhenReady = currentPage == page |
73 | | - playerPage.seekToDefaultPosition(playerConfig.second) |
74 | | - playerPage |
75 | | - } else { |
76 | | - null |
| 88 | + LaunchedEffect(page) { |
| 89 | + storyViewModel.setupPlayerForPage(page) |
77 | 90 | } |
78 | | - player?.let { |
79 | | - PlayerSurface( |
80 | | - modifier = Modifier.fillMaxHeight(), |
81 | | - scaleMode = ScaleMode.Crop, |
82 | | - // Using Texture instead of Surface because on Android API 34 animations are not working well due to the hack |
83 | | - // See PlayerSurfaceView in AndroidPlayerSurfaceView |
84 | | - surfaceType = SurfaceType.Texture, |
85 | | - player = player, |
86 | | - ) |
87 | | - } |
88 | | - Text(text = "Page $page") |
| 91 | + movablePlayerView[page % movablePlayerView.size]() |
89 | 92 | } |
90 | | - Row( |
91 | | - Modifier |
| 93 | + |
| 94 | + PagerIndicator( |
| 95 | + currentPage = settledPage, |
| 96 | + pageCount = mediaItems.size, |
| 97 | + modifier = Modifier |
| 98 | + .align(Alignment.CenterEnd) |
| 99 | + .padding(end = MaterialTheme.paddings.small), |
| 100 | + ) |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +@Composable |
| 105 | +private fun PlayerView(player: Player, modifier: Modifier = Modifier) { |
| 106 | + val progress by remember { |
| 107 | + player.currentPositionAsFlow(100.milliseconds) |
| 108 | + .map { it / player.duration.coerceAtLeast(1L).toFloat() } |
| 109 | + }.collectAsState(0f) |
| 110 | + |
| 111 | + val isBuffering by remember { |
| 112 | + player.playbackStateAsFlow().map { it == Player.STATE_BUFFERING } |
| 113 | + }.collectAsState(false) |
| 114 | + |
| 115 | + Box( |
| 116 | + modifier = modifier, |
| 117 | + ) { |
| 118 | + PlayerSurface( |
| 119 | + modifier = Modifier.fillMaxHeight(), |
| 120 | + scaleMode = ScaleMode.Crop, |
| 121 | + surfaceType = if (Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE) SurfaceType.Texture else SurfaceType.Surface, |
| 122 | + player = player, |
| 123 | + defaultAspectRatio = 9 / 16f, |
| 124 | + ) |
| 125 | + |
| 126 | + if (isBuffering) { |
| 127 | + CircularProgressIndicator( |
| 128 | + color = Color.White, |
| 129 | + modifier = Modifier.align(Alignment.Center), |
| 130 | + ) |
| 131 | + } |
| 132 | + |
| 133 | + LinearProgressIndicator( |
| 134 | + progress = { progress }, |
| 135 | + modifier = Modifier |
92 | 136 | .fillMaxWidth() |
93 | | - .align(Alignment.BottomCenter) |
94 | | - .padding(bottom = MaterialTheme.paddings.baseline), |
95 | | - horizontalArrangement = Arrangement.Center |
96 | | - ) { |
97 | | - repeat(playlist.size) { iteration -> |
98 | | - val color = if (pagerState.currentPage == iteration) ColorIndicatorCurrent else ColorIndicator |
99 | | - Box( |
100 | | - modifier = Modifier |
101 | | - .padding(MaterialTheme.paddings.micro) |
102 | | - .size(IndicatorSize) |
103 | | - .drawBehind { |
104 | | - drawCircle(color) |
105 | | - } |
106 | | - |
107 | | - ) |
108 | | - } |
| 137 | + .align(Alignment.BottomCenter), |
| 138 | + color = PrimaryComponentColor, |
| 139 | + trackColor = SecondaryComponentColor, |
| 140 | + gapSize = 0.dp, |
| 141 | + drawStopIndicator = {}, |
| 142 | + ) |
| 143 | + } |
| 144 | +} |
| 145 | + |
| 146 | +@Composable |
| 147 | +private fun PagerIndicator( |
| 148 | + currentPage: Int, |
| 149 | + pageCount: Int, |
| 150 | + modifier: Modifier = Modifier, |
| 151 | +) { |
| 152 | + Column( |
| 153 | + modifier = modifier |
| 154 | + .background( |
| 155 | + color = SurfaceComponentColor, |
| 156 | + shape = CircleShape, |
| 157 | + ) |
| 158 | + .padding(MaterialTheme.paddings.micro), |
| 159 | + ) { |
| 160 | + repeat(pageCount) { index -> |
| 161 | + val dotColor by animateColorAsState( |
| 162 | + targetValue = if (currentPage == index) PrimaryComponentColor else SecondaryComponentColor, |
| 163 | + label = "indicator-animation", |
| 164 | + ) |
| 165 | + |
| 166 | + Box( |
| 167 | + modifier = Modifier |
| 168 | + .padding(MaterialTheme.paddings.micro) |
| 169 | + .size(IndicatorSize) |
| 170 | + .drawBehind { |
| 171 | + drawCircle(dotColor) |
| 172 | + }, |
| 173 | + ) |
109 | 174 | } |
110 | 175 | } |
111 | 176 | } |
112 | 177 |
|
113 | | -private val ColorIndicatorCurrent = Color.LightGray.copy(alpha = 0.75f) |
114 | | -private val ColorIndicator = Color.LightGray.copy(alpha = 0.25f) |
| 178 | +@Preview |
| 179 | +@Composable |
| 180 | +private fun PageIndicatorPreview() { |
| 181 | + val pageCount = 5 |
| 182 | + var step by remember { mutableIntStateOf(1) } |
| 183 | + var currentPage by remember { mutableIntStateOf(0) } |
| 184 | + |
| 185 | + LaunchedEffect(currentPage) { |
| 186 | + delay(1.seconds) |
| 187 | + currentPage += step |
| 188 | + |
| 189 | + if (currentPage == pageCount - 1) { |
| 190 | + step = -1 |
| 191 | + } else if (currentPage == 0) { |
| 192 | + step = 1 |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + PillarboxTheme { |
| 197 | + PagerIndicator( |
| 198 | + currentPage = currentPage, |
| 199 | + pageCount = pageCount, |
| 200 | + ) |
| 201 | + } |
| 202 | +} |
| 203 | + |
| 204 | +private val ComponentColor = Color.LightGray |
| 205 | +private val PrimaryComponentColor = ComponentColor.copy(alpha = 0.88f) |
| 206 | +private val SecondaryComponentColor = ComponentColor.copy(alpha = 0.33f) |
| 207 | +private val SurfaceComponentColor = ComponentColor.copy(alpha = 0.25f) |
115 | 208 | private val IndicatorSize = 12.dp |
0 commit comments