Skip to content

Commit af8c04e

Browse files
committed
feat: complete Shorts interactions
1 parent 1a2cb71 commit af8c04e

5 files changed

Lines changed: 206 additions & 55 deletions

File tree

app/src/androidTest/java/dev/typetype/android/feature/shorts/ShortsScreenTest.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,33 @@ class ShortsScreenTest {
9999
assertEquals(VideoMenuAction.ToggleFavorite, selectedAction.get())
100100
}
101101

102+
@Test
103+
fun activeShortExposesCommentsAndSubscription() {
104+
val commentsVideo = AtomicReference<Video>()
105+
val subscribedVideo = AtomicReference<Video>()
106+
107+
show(
108+
state = ShortsState(videos = listOf(video("one")), isLoading = false),
109+
embeddedPlaybackEnabled = true,
110+
onShowComments = commentsVideo::set,
111+
onToggleSubscription = subscribedVideo::set,
112+
)
113+
114+
composeRule.onNodeWithContentDescription("Comments").performClick()
115+
composeRule.onNodeWithText("Subscribe").performClick()
116+
assertEquals("one", commentsVideo.get().id)
117+
assertEquals("one", subscribedVideo.get().id)
118+
}
119+
102120
private fun show(
103121
state: ShortsState,
104122
onPlayVideo: (String) -> Unit = {},
105123
embeddedPlaybackEnabled: Boolean = false,
106124
onActiveVideoChanged: (Video?) -> Unit = {},
107125
embeddedPlayback: @Composable (Video, () -> Unit) -> Unit = { _, _ -> },
108126
onMenuAction: (VideoMenuAction, Video) -> Unit = { _, _ -> },
127+
onShowComments: ((Video) -> Unit)? = null,
128+
onToggleSubscription: (Video) -> Unit = {},
109129
) {
110130
composeRule.setContent {
111131
TypeTypeTheme {
@@ -119,6 +139,8 @@ class ShortsScreenTest {
119139
onActiveVideoChanged = onActiveVideoChanged,
120140
embeddedPlayback = embeddedPlayback,
121141
onMenuAction = onMenuAction,
142+
onShowComments = onShowComments,
143+
onToggleSubscription = onToggleSubscription,
122144
)
123145
}
124146
}

app/src/main/java/dev/typetype/android/feature/player/PlayerChannelActionsViewModel.kt

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,22 @@ class PlayerChannelActionsViewModel @Inject constructor(
4646
}
4747

4848
fun toggle(stream: Stream) {
49-
val channelUrl = canonicalChannelUrl(stream.uploaderUrl)
50-
if (channelUrl.isBlank() || mutableState.value.updatingUrl != null) return
51-
val subscribed = mutableState.value.isSubscribed(channelUrl)
49+
toggle(stream.uploaderUrl, stream.uploaderName, stream.uploaderAvatarUrl)
50+
}
51+
52+
fun toggle(channelUrl: String, name: String, avatarUrl: String) {
53+
val canonicalUrl = canonicalChannelUrl(channelUrl)
54+
if (canonicalUrl.isBlank() || mutableState.value.updatingUrl != null) return
55+
val subscribed = mutableState.value.isSubscribed(canonicalUrl)
5256
viewModelScope.launch {
53-
mutableState.update { it.copy(updatingUrl = channelUrl) }
57+
mutableState.update { it.copy(updatingUrl = canonicalUrl) }
5458
val result = if (subscribed) {
55-
subscriptionsRepository.unsubscribe(channelUrl)
59+
subscriptionsRepository.unsubscribe(canonicalUrl)
5660
} else {
5761
subscriptionsRepository.subscribe(
58-
channelUrl = channelUrl,
59-
name = stream.uploaderName,
60-
avatarUrl = stream.uploaderAvatarUrl,
62+
channelUrl = canonicalUrl,
63+
name = name,
64+
avatarUrl = avatarUrl,
6165
)
6266
}
6367
mutableState.update { it.copy(updatingUrl = null) }
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package dev.typetype.android.feature.shorts
2+
3+
import androidx.compose.foundation.clickable
4+
import androidx.compose.foundation.layout.Arrangement
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.Row
7+
import androidx.compose.foundation.layout.Spacer
8+
import androidx.compose.foundation.layout.fillMaxWidth
9+
import androidx.compose.foundation.layout.padding
10+
import androidx.compose.foundation.layout.size
11+
import androidx.compose.foundation.layout.width
12+
import androidx.compose.foundation.shape.CircleShape
13+
import androidx.compose.foundation.shape.RoundedCornerShape
14+
import androidx.compose.material3.Button
15+
import androidx.compose.material3.ButtonDefaults
16+
import androidx.compose.material3.Text
17+
import androidx.compose.runtime.Composable
18+
import androidx.compose.ui.Alignment
19+
import androidx.compose.ui.Modifier
20+
import androidx.compose.ui.draw.clip
21+
import androidx.compose.ui.graphics.Color
22+
import androidx.compose.ui.layout.ContentScale
23+
import androidx.compose.ui.res.stringResource
24+
import androidx.compose.ui.semantics.Role
25+
import androidx.compose.ui.text.font.FontWeight
26+
import androidx.compose.ui.text.style.TextOverflow
27+
import androidx.compose.ui.unit.dp
28+
import coil3.compose.AsyncImage
29+
import dev.typetype.android.R
30+
import dev.typetype.android.domain.feed.Video
31+
32+
@Composable
33+
internal fun ShortsInfoOverlay(
34+
video: Video,
35+
title: String,
36+
isSubscribed: Boolean,
37+
subscriptionInFlight: Boolean,
38+
onOpenChannel: () -> Unit,
39+
onToggleSubscription: () -> Unit,
40+
modifier: Modifier = Modifier,
41+
) {
42+
Column(
43+
modifier = modifier.fillMaxWidth()
44+
.padding(start = 20.dp, top = 20.dp, end = 80.dp, bottom = 20.dp),
45+
verticalArrangement = Arrangement.spacedBy(10.dp),
46+
) {
47+
Row(verticalAlignment = Alignment.CenterVertically) {
48+
Row(
49+
verticalAlignment = Alignment.CenterVertically,
50+
modifier = Modifier
51+
.weight(1f)
52+
.clip(RoundedCornerShape(999.dp))
53+
.clickable(role = Role.Button, onClick = onOpenChannel)
54+
.padding(vertical = 6.dp, horizontal = 8.dp),
55+
) {
56+
AsyncImage(
57+
model = video.uploaderAvatarUrl,
58+
contentDescription = null,
59+
contentScale = ContentScale.Crop,
60+
modifier = Modifier.size(34.dp).clip(CircleShape),
61+
)
62+
Spacer(Modifier.width(10.dp))
63+
Text(
64+
text = video.uploaderName,
65+
color = Color.White,
66+
fontWeight = FontWeight.Medium,
67+
maxLines = 1,
68+
overflow = TextOverflow.Ellipsis,
69+
)
70+
}
71+
if (video.uploaderUrl.isNotBlank()) {
72+
Spacer(Modifier.width(8.dp))
73+
Button(
74+
onClick = onToggleSubscription,
75+
enabled = !subscriptionInFlight,
76+
shape = RoundedCornerShape(20.dp),
77+
colors = ButtonDefaults.buttonColors(
78+
containerColor = if (isSubscribed) {
79+
Color.Black.copy(alpha = 0.68f)
80+
} else {
81+
Color.White
82+
},
83+
contentColor = if (isSubscribed) Color.White else Color.Black,
84+
),
85+
contentPadding = androidx.compose.foundation.layout.PaddingValues(
86+
horizontal = 12.dp,
87+
vertical = 6.dp,
88+
),
89+
) {
90+
Text(
91+
text = stringResource(
92+
if (isSubscribed) R.string.channel_subscribed
93+
else R.string.channel_subscribe,
94+
),
95+
fontWeight = FontWeight.SemiBold,
96+
)
97+
}
98+
}
99+
}
100+
Text(
101+
text = title,
102+
color = Color.White,
103+
fontWeight = FontWeight.SemiBold,
104+
maxLines = 3,
105+
overflow = TextOverflow.Ellipsis,
106+
)
107+
}
108+
}

app/src/main/java/dev/typetype/android/feature/shorts/ShortsRoute.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
package dev.typetype.android.feature.shorts
22

3+
import androidx.compose.material3.SnackbarDuration
34
import androidx.compose.runtime.Composable
45
import androidx.compose.runtime.DisposableEffect
56
import androidx.compose.runtime.LaunchedEffect
67
import androidx.compose.runtime.getValue
8+
import androidx.compose.runtime.mutableStateOf
9+
import androidx.compose.runtime.remember
10+
import androidx.compose.runtime.setValue
11+
import androidx.compose.ui.res.stringResource
712
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
813
import androidx.lifecycle.compose.collectAsStateWithLifecycle
14+
import dev.typetype.android.R
15+
import dev.typetype.android.core.ui.components.LocalAppSnackbarHost
916
import dev.typetype.android.feature.menu.rememberVideoMenuScope
17+
import dev.typetype.android.feature.player.PlayerChannelActionsViewModel
1018
import dev.typetype.android.feature.player.PlayerViewModel
1119
import dev.typetype.android.feature.player.ShortsPlayerRoute
20+
import dev.typetype.android.feature.player.components.CommentsSheet
1221
import dev.typetype.android.feature.player.components.LocalMediaController
1322
import dev.typetype.android.feature.player.host.PlayerHostController
1423
import dev.typetype.android.feature.player.host.PlayerHostTarget
@@ -20,12 +29,24 @@ fun ShortsRoute(
2029
playerHostController: PlayerHostController,
2130
playerViewModel: PlayerViewModel,
2231
viewModel: ShortsViewModel = hiltViewModel(),
32+
channelActionsViewModel: PlayerChannelActionsViewModel = hiltViewModel(),
2333
) {
2434
val state by viewModel.state.collectAsStateWithLifecycle()
35+
val playerState by playerViewModel.state.collectAsStateWithLifecycle()
36+
val channelState by channelActionsViewModel.state.collectAsStateWithLifecycle()
2537
val playerHostState by playerHostController.state.collectAsStateWithLifecycle()
2638
val mediaController = LocalMediaController.current
2739
val menuScope = rememberVideoMenuScope(onOpenChannel)
2840
val visibleState = state.copy(videos = state.videos.filterNot(menuScope::isHidden))
41+
val snackbarHost = LocalAppSnackbarHost.current
42+
val actionFailed = stringResource(R.string.snackbar_action_failed)
43+
var commentsVideoUrl by remember { mutableStateOf<String?>(null) }
44+
45+
LaunchedEffect(channelActionsViewModel, snackbarHost, actionFailed) {
46+
channelActionsViewModel.events.collect {
47+
snackbarHost?.showSnackbar(actionFailed, duration = SnackbarDuration.Short)
48+
}
49+
}
2950

3051
DisposableEffect(playerHostController) {
3152
onDispose { playerHostController.closeEmbeddedPlayback() }
@@ -53,6 +74,18 @@ fun ShortsRoute(
5374
onLoadMore = { viewModel.onAction(ShortsAction.LoadMore) },
5475
menuItemState = menuScope::stateFor,
5576
onMenuAction = { action, video -> menuScope.onAction(action, video) },
77+
onShowComments = if (playerState.userSettings.hideComments) null else {
78+
{ video -> commentsVideoUrl = video.url }
79+
},
80+
isSubscribed = { channelState.isSubscribed(it.uploaderUrl) },
81+
subscriptionInFlight = { channelState.isUpdating(it.uploaderUrl) },
82+
onToggleSubscription = { video ->
83+
channelActionsViewModel.toggle(
84+
video.uploaderUrl,
85+
video.uploaderName,
86+
video.uploaderAvatarUrl,
87+
)
88+
},
5689
embeddedPlaybackEnabled = true,
5790
onActiveVideoChanged = { video ->
5891
if (video == null) {
@@ -77,4 +110,17 @@ fun ShortsRoute(
77110
}
78111
},
79112
)
113+
114+
commentsVideoUrl?.let { videoUrl ->
115+
CommentsSheet(
116+
pagingFlow = playerViewModel.comments,
117+
videoUrl = videoUrl,
118+
commentsRepository = playerViewModel.commentsRepository,
119+
onDismiss = { commentsVideoUrl = null },
120+
onTimestampClick = { positionMillis ->
121+
mediaController?.seekTo(positionMillis)
122+
commentsVideoUrl = null
123+
},
124+
)
125+
}
80126
}

app/src/main/java/dev/typetype/android/feature/shorts/ShortsScreen.kt

Lines changed: 18 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,13 @@ package dev.typetype.android.feature.shorts
22

33
import androidx.compose.foundation.ExperimentalFoundationApi
44
import androidx.compose.foundation.background
5-
import androidx.compose.foundation.clickable
6-
import androidx.compose.foundation.layout.Arrangement
75
import androidx.compose.foundation.layout.Box
86
import androidx.compose.foundation.layout.Column
9-
import androidx.compose.foundation.layout.Row
10-
import androidx.compose.foundation.layout.Spacer
117
import androidx.compose.foundation.layout.fillMaxSize
12-
import androidx.compose.foundation.layout.fillMaxWidth
138
import androidx.compose.foundation.layout.padding
149
import androidx.compose.foundation.layout.size
15-
import androidx.compose.foundation.layout.width
1610
import androidx.compose.foundation.pager.VerticalPager
1711
import androidx.compose.foundation.pager.rememberPagerState
18-
import androidx.compose.foundation.shape.CircleShape
1912
import androidx.compose.foundation.shape.RoundedCornerShape
2013
import androidx.compose.material.icons.Icons
2114
import androidx.compose.material.icons.filled.PlayArrow
@@ -36,15 +29,11 @@ import androidx.compose.runtime.rememberUpdatedState
3629
import androidx.compose.runtime.snapshotFlow
3730
import androidx.compose.ui.Alignment
3831
import androidx.compose.ui.Modifier
39-
import androidx.compose.ui.draw.clip
4032
import androidx.compose.ui.graphics.Brush
4133
import androidx.compose.ui.graphics.Color
4234
import androidx.compose.ui.layout.ContentScale
4335
import androidx.compose.ui.platform.testTag
4436
import androidx.compose.ui.res.stringResource
45-
import androidx.compose.ui.semantics.Role
46-
import androidx.compose.ui.text.font.FontWeight
47-
import androidx.compose.ui.text.style.TextOverflow
4837
import androidx.compose.ui.unit.dp
4938
import coil3.compose.AsyncImage
5039
import dev.typetype.android.R
@@ -73,6 +62,9 @@ fun ShortsScreen(
7362
menuItemState: (Video) -> VideoMenuItemState = { VideoMenuItemState() },
7463
onMenuAction: (VideoMenuAction, Video) -> Unit = { _, _ -> },
7564
onShowComments: ((Video) -> Unit)? = null,
65+
isSubscribed: (Video) -> Boolean = { false },
66+
subscriptionInFlight: (Video) -> Boolean = { false },
67+
onToggleSubscription: (Video) -> Unit = {},
7668
) {
7769
when {
7870
state.isLoading && state.videos.isEmpty() -> FullScreenLoader()
@@ -121,6 +113,9 @@ fun ShortsScreen(
121113
onShowComments = onShowComments?.let { callback ->
122114
{ callback(state.videos[page]) }
123115
},
116+
isSubscribed = isSubscribed(state.videos[page]),
117+
subscriptionInFlight = subscriptionInFlight(state.videos[page]),
118+
onToggleSubscription = { onToggleSubscription(state.videos[page]) },
124119
embeddedPlayback = {
125120
embeddedPlayback(state.videos[page]) {
126121
if (page < state.videos.lastIndex) {
@@ -193,6 +188,9 @@ private fun ShortPage(
193188
menuItemState: VideoMenuItemState,
194189
onMenuAction: (VideoMenuAction) -> Unit,
195190
onShowComments: (() -> Unit)?,
191+
isSubscribed: Boolean,
192+
subscriptionInFlight: Boolean,
193+
onToggleSubscription: () -> Unit,
196194
embeddedPlayback: @Composable () -> Unit,
197195
) {
198196
val branding = rememberVideoBranding(
@@ -239,42 +237,15 @@ private fun ShortPage(
239237
)
240238
}
241239
}
242-
Column(
243-
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth()
244-
.padding(start = 20.dp, top = 20.dp, end = 80.dp, bottom = 20.dp),
245-
verticalArrangement = Arrangement.spacedBy(10.dp),
246-
) {
247-
Text(
248-
text = branding.title,
249-
style = MaterialTheme.typography.titleLarge,
250-
color = Color.White,
251-
fontWeight = FontWeight.SemiBold,
252-
maxLines = 3,
253-
overflow = TextOverflow.Ellipsis,
254-
)
255-
Row(
256-
verticalAlignment = Alignment.CenterVertically,
257-
modifier = Modifier
258-
.clip(RoundedCornerShape(999.dp))
259-
.clickable(role = Role.Button) { onOpenChannel(video.uploaderUrl) }
260-
.padding(vertical = 6.dp, horizontal = 8.dp),
261-
) {
262-
AsyncImage(
263-
model = video.uploaderAvatarUrl,
264-
contentDescription = null,
265-
contentScale = ContentScale.Crop,
266-
modifier = Modifier.size(34.dp).clip(CircleShape),
267-
)
268-
Spacer(Modifier.width(10.dp))
269-
Text(
270-
text = video.uploaderName,
271-
color = Color.White,
272-
style = MaterialTheme.typography.labelLarge,
273-
maxLines = 1,
274-
overflow = TextOverflow.Ellipsis,
275-
)
276-
}
277-
}
240+
ShortsInfoOverlay(
241+
video = video,
242+
title = branding.title,
243+
isSubscribed = isSubscribed,
244+
subscriptionInFlight = subscriptionInFlight,
245+
onOpenChannel = { onOpenChannel(video.uploaderUrl) },
246+
onToggleSubscription = onToggleSubscription,
247+
modifier = Modifier.align(Alignment.BottomStart),
248+
)
278249
}
279250
}
280251

0 commit comments

Comments
 (0)