ui: Eased card-row scrolling, dark text on focused white cards
Focused-card rows now animate the newly focused card to the left edge (animateScrollToItem) instead of the abrupt bring-into-view jump - SmartTube-style easing. Text inside a focused (white) card flips to a dark gray-on-white palette instead of staying light gray. LEFT on the first card of a row/chain opens the menu drawer.
This commit is contained in:
parent
9635e31589
commit
60ef94c52e
5 changed files with 113 additions and 38 deletions
|
|
@ -66,6 +66,7 @@ class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel<Vide
|
|||
fun ChannelsScreen(
|
||||
onOpenChannel: (handle: String) -> Unit,
|
||||
epoch: Int = 0,
|
||||
onOpenDrawer: () -> Unit = {},
|
||||
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
|
||||
) {
|
||||
LaunchedEffect(Unit) { viewModel.loadMore(force = true) }
|
||||
|
|
@ -95,7 +96,7 @@ fun ChannelsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
else -> ChannelsList(items, viewModel, onOpenChannel)
|
||||
else -> ChannelsList(items, viewModel, onOpenChannel, onOpenDrawer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -105,12 +106,14 @@ private fun ChannelsList(
|
|||
items: List<VideoChannelDto>,
|
||||
viewModel: ChannelsViewModel,
|
||||
onOpenChannel: (String) -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) {
|
||||
itemsIndexed(items) { index, channel ->
|
||||
ChannelCard(
|
||||
channel = channel,
|
||||
onClick = { onOpenChannel("${channel.name}@${channel.host}") },
|
||||
onLeftAtStart = if (index == 0) onOpenDrawer else null,
|
||||
)
|
||||
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
|
||||
LaunchedEffect(index) { viewModel.loadMore() }
|
||||
|
|
@ -127,7 +130,7 @@ private fun ChannelsList(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit) {
|
||||
private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtStart: (() -> Unit)? = null) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -11,12 +11,15 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -130,6 +133,7 @@ fun HomeRowsScreen(
|
|||
onOpenVideo: (VideoDto) -> Unit,
|
||||
restoreToken: Int = 0,
|
||||
epoch: Int = 0,
|
||||
onOpenDrawer: () -> Unit = {},
|
||||
viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory),
|
||||
) {
|
||||
LaunchedEffect(Unit) { viewModel.load() }
|
||||
|
|
@ -154,21 +158,14 @@ fun HomeRowsScreen(
|
|||
)
|
||||
}
|
||||
} else {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
itemsIndexed(viewModel.continueItems) { _, item ->
|
||||
VideoCard(
|
||||
video = item.video,
|
||||
onClick = { onOpenVideo(item.video) },
|
||||
modifier = Modifier.width(rowCardWidth()),
|
||||
showPrivacy = false,
|
||||
progress = item.progress,
|
||||
compact = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
FocusScrollRow(
|
||||
videos = viewModel.continueItems.map { it.video },
|
||||
progressOf = { shortUUID ->
|
||||
viewModel.continueItems.firstOrNull { it.video.shortUUID == shortUUID }?.progress
|
||||
},
|
||||
onOpenVideo = onOpenVideo,
|
||||
onLeftAtStart = onOpenDrawer,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
|
|
@ -190,24 +187,63 @@ fun HomeRowsScreen(
|
|||
)
|
||||
}
|
||||
} else {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
itemsIndexed(recentItems) { index, video ->
|
||||
VideoCard(
|
||||
video = video,
|
||||
onClick = { onOpenVideo(video) },
|
||||
modifier = Modifier.width(rowCardWidth()),
|
||||
showPrivacy = false,
|
||||
compact = true,
|
||||
)
|
||||
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
|
||||
LaunchedEffect(index) { viewModel.recent.loadMore() }
|
||||
}
|
||||
FocusScrollRow(
|
||||
videos = recentItems,
|
||||
progressOf = { null },
|
||||
onOpenVideo = onOpenVideo,
|
||||
onLeftAtStart = onOpenDrawer,
|
||||
) { index, video ->
|
||||
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
|
||||
LaunchedEffect(index) { viewModel.recent.loadMore() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Horizontal card row with eased scrolling: when focus moves to a card outside the
|
||||
* viewport, the row animates it to the left edge (SmartTube behaviour) instead of
|
||||
* the abrupt jump the built-in lazy scroll produces.
|
||||
*/
|
||||
@Composable
|
||||
private fun FocusScrollRow(
|
||||
videos: List<VideoDto>,
|
||||
progressOf: (String) -> Float?,
|
||||
onOpenVideo: (VideoDto) -> Unit,
|
||||
onLeftAtStart: () -> Unit,
|
||||
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val rowState = rememberLazyListState()
|
||||
var focusedIndex by remember { androidx.compose.runtime.mutableStateOf(-1) }
|
||||
LaunchedEffect(focusedIndex) {
|
||||
if (focusedIndex > 0) {
|
||||
val visible = rowState.layoutInfo.visibleItemsInfo
|
||||
val target = visible.firstOrNull { it.index == focusedIndex }
|
||||
// animate only when the focused card is not (fully) on screen
|
||||
val notFullyVisible = target == null ||
|
||||
target.offset < 0 ||
|
||||
target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset
|
||||
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
|
||||
}
|
||||
}
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
itemsIndexed(videos) { index, video ->
|
||||
VideoCard(
|
||||
video = video,
|
||||
onClick = { onOpenVideo(video) },
|
||||
modifier = Modifier.width(rowCardWidth()),
|
||||
showPrivacy = false,
|
||||
compact = true,
|
||||
progress = progressOf(video.shortUUID),
|
||||
onLeftAtStart = if (index == 0) onLeftAtStart else null,
|
||||
onFocused = { focusedIndex = index },
|
||||
)
|
||||
afterItem(index, video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
onOpenVideo = { playingVideo = it.shortUUID },
|
||||
restoreToken = restoreToken,
|
||||
epoch = epoch,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
)
|
||||
Destination.MyVideos ->
|
||||
VideoListScreen(
|
||||
|
|
@ -123,10 +124,12 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
onOpenVideo = { playingVideo = it.shortUUID },
|
||||
restoreToken = restoreToken,
|
||||
epoch = epoch,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
)
|
||||
Destination.Channels -> ChannelsScreen(
|
||||
onOpenChannel = { handle -> channelDetails = handle },
|
||||
epoch = epoch,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
)
|
||||
Destination.Search ->
|
||||
VideoListScreen(
|
||||
|
|
@ -134,6 +137,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
onOpenVideo = { playingVideo = it.shortUUID },
|
||||
restoreToken = restoreToken,
|
||||
epoch = epoch,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
)
|
||||
Destination.Subscriptions ->
|
||||
VideoListScreen(
|
||||
|
|
@ -141,6 +145,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
onOpenVideo = { playingVideo = it.shortUUID },
|
||||
restoreToken = restoreToken,
|
||||
epoch = epoch,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
)
|
||||
Destination.History ->
|
||||
VideoListScreen(
|
||||
|
|
@ -148,6 +153,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
onOpenVideo = { playingVideo = it.shortUUID },
|
||||
restoreToken = restoreToken,
|
||||
epoch = epoch,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
|
@ -48,10 +57,29 @@ fun VideoCard(
|
|||
progress: Float? = null,
|
||||
/** Compact = row in the SmartTube home: single-line title, smaller. */
|
||||
compact: Boolean = false,
|
||||
/** LEFT (only on the row's first card) can open the menu drawer. */
|
||||
onLeftAtStart: (() -> Unit)? = null,
|
||||
/** Notifies the row wrapper when this card gains focus (for eased scrolling). */
|
||||
onFocused: (() -> Unit)? = null,
|
||||
) {
|
||||
var cardFocused by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier.padding(6.dp),
|
||||
modifier = modifier
|
||||
.padding(6.dp)
|
||||
.onFocusChanged {
|
||||
cardFocused = it.isFocused
|
||||
if (it.isFocused) onFocused?.invoke()
|
||||
}
|
||||
.onPreviewKeyEvent { ke ->
|
||||
if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown &&
|
||||
ke.key == androidx.compose.ui.input.key.Key.DirectionLeft &&
|
||||
onLeftAtStart != null
|
||||
) {
|
||||
onLeftAtStart()
|
||||
true
|
||||
} else false
|
||||
},
|
||||
colors = CardDefaults.colors(
|
||||
containerColor = UnfocusedCardBg,
|
||||
contentColor = Color.White,
|
||||
|
|
@ -127,6 +155,7 @@ fun VideoCard(
|
|||
maxLines = if (compact) 1 else 2,
|
||||
minLines = if (compact) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (cardFocused) Color(0xFF101010) else Color.White,
|
||||
)
|
||||
|
||||
Row(
|
||||
|
|
@ -136,7 +165,7 @@ fun VideoCard(
|
|||
Text(
|
||||
text = video.channel.displayName.ifBlank { video.channel.name },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFFB8B8B8),
|
||||
color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFFB8B8B8),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
|
|
@ -165,7 +194,7 @@ fun VideoCard(
|
|||
text = formatAge(video.publishedAt) +
|
||||
if (video.views > 0) " · " + formatViews(video.views) else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF8A8A8A),
|
||||
color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ fun VideoListScreen(
|
|||
onOpenVideo: (VideoDto) -> Unit,
|
||||
restoreToken: Int = 0,
|
||||
epoch: Int = 0,
|
||||
onOpenDrawer: () -> Unit = {},
|
||||
viewModel: VideoListViewModel = viewModel(
|
||||
key = "v$epoch:" + sourceKey(source),
|
||||
factory = VideoListViewModel.factory(source),
|
||||
|
|
@ -135,9 +136,9 @@ fun VideoListScreen(
|
|||
video = video,
|
||||
onClick = { lastOpenedIndex = index; onOpenVideo(video) },
|
||||
showPrivacy = source is ListSource.MyVideos,
|
||||
onLeftAtStart = if (index == 0) onOpenDrawer else null,
|
||||
modifier = Modifier
|
||||
.focusRequester(fr)
|
||||
.also { if (index == 0) Modifier.focusRequester(initialFocus) },
|
||||
.focusRequester(fr),
|
||||
)
|
||||
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
|
||||
LaunchedEffect(index) { viewModel.loadMore() }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue