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:
Jeena 2026-10-10 22:24:06 +00:00
parent 9635e31589
commit 60ef94c52e
5 changed files with 113 additions and 38 deletions

View file

@ -66,6 +66,7 @@ class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel<Vide
fun ChannelsScreen( fun ChannelsScreen(
onOpenChannel: (handle: String) -> Unit, onOpenChannel: (handle: String) -> Unit,
epoch: Int = 0, epoch: Int = 0,
onOpenDrawer: () -> Unit = {},
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
) { ) {
LaunchedEffect(Unit) { viewModel.loadMore(force = true) } 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>, items: List<VideoChannelDto>,
viewModel: ChannelsViewModel, viewModel: ChannelsViewModel,
onOpenChannel: (String) -> Unit, onOpenChannel: (String) -> Unit,
onOpenDrawer: () -> Unit,
) { ) {
LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) {
itemsIndexed(items) { index, channel -> itemsIndexed(items) { index, channel ->
ChannelCard( ChannelCard(
channel = channel, channel = channel,
onClick = { onOpenChannel("${channel.name}@${channel.host}") }, onClick = { onOpenChannel("${channel.name}@${channel.host}") },
onLeftAtStart = if (index == 0) onOpenDrawer else null,
) )
if (index >= items.size - PagedListViewModel.LOOKAHEAD) { if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.loadMore() } LaunchedEffect(index) { viewModel.loadMore() }
@ -127,7 +130,7 @@ private fun ChannelsList(
} }
@Composable @Composable
private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit) { private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtStart: (() -> Unit)? = null) {
Card( Card(
onClick = onClick, onClick = onClick,
modifier = Modifier modifier = Modifier

View file

@ -11,12 +11,15 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -130,6 +133,7 @@ fun HomeRowsScreen(
onOpenVideo: (VideoDto) -> Unit, onOpenVideo: (VideoDto) -> Unit,
restoreToken: Int = 0, restoreToken: Int = 0,
epoch: Int = 0, epoch: Int = 0,
onOpenDrawer: () -> Unit = {},
viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory),
) { ) {
LaunchedEffect(Unit) { viewModel.load() } LaunchedEffect(Unit) { viewModel.load() }
@ -154,21 +158,14 @@ fun HomeRowsScreen(
) )
} }
} else { } else {
LazyRow( FocusScrollRow(
contentPadding = PaddingValues(horizontal = 12.dp), videos = viewModel.continueItems.map { it.video },
horizontalArrangement = Arrangement.spacedBy(4.dp), progressOf = { shortUUID ->
) { viewModel.continueItems.firstOrNull { it.video.shortUUID == shortUUID }?.progress
itemsIndexed(viewModel.continueItems) { _, item -> },
VideoCard( onOpenVideo = onOpenVideo,
video = item.video, onLeftAtStart = onOpenDrawer,
onClick = { onOpenVideo(item.video) }, )
modifier = Modifier.width(rowCardWidth()),
showPrivacy = false,
progress = item.progress,
compact = true,
)
}
}
} }
} }
item { item {
@ -190,24 +187,63 @@ fun HomeRowsScreen(
) )
} }
} else { } else {
LazyRow( FocusScrollRow(
contentPadding = PaddingValues(horizontal = 12.dp), videos = recentItems,
horizontalArrangement = Arrangement.spacedBy(4.dp), progressOf = { null },
) { onOpenVideo = onOpenVideo,
itemsIndexed(recentItems) { index, video -> onLeftAtStart = onOpenDrawer,
VideoCard( ) { index, video ->
video = video, if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
onClick = { onOpenVideo(video) }, LaunchedEffect(index) { viewModel.recent.loadMore() }
modifier = Modifier.width(rowCardWidth()),
showPrivacy = false,
compact = true,
)
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)
}
}
}

View file

@ -116,6 +116,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true },
) )
Destination.MyVideos -> Destination.MyVideos ->
VideoListScreen( VideoListScreen(
@ -123,10 +124,12 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true },
) )
Destination.Channels -> ChannelsScreen( Destination.Channels -> ChannelsScreen(
onOpenChannel = { handle -> channelDetails = handle }, onOpenChannel = { handle -> channelDetails = handle },
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true },
) )
Destination.Search -> Destination.Search ->
VideoListScreen( VideoListScreen(
@ -134,6 +137,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true },
) )
Destination.Subscriptions -> Destination.Subscriptions ->
VideoListScreen( VideoListScreen(
@ -141,6 +145,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true },
) )
Destination.History -> Destination.History ->
VideoListScreen( VideoListScreen(
@ -148,6 +153,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true },
) )
} }
} }

View file

@ -13,9 +13,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -48,10 +57,29 @@ fun VideoCard(
progress: Float? = null, progress: Float? = null,
/** Compact = row in the SmartTube home: single-line title, smaller. */ /** Compact = row in the SmartTube home: single-line title, smaller. */
compact: Boolean = false, 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( Card(
onClick = onClick, 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( colors = CardDefaults.colors(
containerColor = UnfocusedCardBg, containerColor = UnfocusedCardBg,
contentColor = Color.White, contentColor = Color.White,
@ -127,6 +155,7 @@ fun VideoCard(
maxLines = if (compact) 1 else 2, maxLines = if (compact) 1 else 2,
minLines = if (compact) 1 else 2, minLines = if (compact) 1 else 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = if (cardFocused) Color(0xFF101010) else Color.White,
) )
Row( Row(
@ -136,7 +165,7 @@ fun VideoCard(
Text( Text(
text = video.channel.displayName.ifBlank { video.channel.name }, text = video.channel.displayName.ifBlank { video.channel.name },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color(0xFFB8B8B8), color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFFB8B8B8),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false), modifier = Modifier.weight(1f, fill = false),
@ -165,7 +194,7 @@ fun VideoCard(
text = formatAge(video.publishedAt) + text = formatAge(video.publishedAt) +
if (video.views > 0) " · " + formatViews(video.views) else "", if (video.views > 0) " · " + formatViews(video.views) else "",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8A8A8A), color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A),
) )
} }
} }

View file

@ -43,6 +43,7 @@ fun VideoListScreen(
onOpenVideo: (VideoDto) -> Unit, onOpenVideo: (VideoDto) -> Unit,
restoreToken: Int = 0, restoreToken: Int = 0,
epoch: Int = 0, epoch: Int = 0,
onOpenDrawer: () -> Unit = {},
viewModel: VideoListViewModel = viewModel( viewModel: VideoListViewModel = viewModel(
key = "v$epoch:" + sourceKey(source), key = "v$epoch:" + sourceKey(source),
factory = VideoListViewModel.factory(source), factory = VideoListViewModel.factory(source),
@ -135,9 +136,9 @@ fun VideoListScreen(
video = video, video = video,
onClick = { lastOpenedIndex = index; onOpenVideo(video) }, onClick = { lastOpenedIndex = index; onOpenVideo(video) },
showPrivacy = source is ListSource.MyVideos, showPrivacy = source is ListSource.MyVideos,
onLeftAtStart = if (index == 0) onOpenDrawer else null,
modifier = Modifier modifier = Modifier
.focusRequester(fr) .focusRequester(fr),
.also { if (index == 0) Modifier.focusRequester(initialFocus) },
) )
if (index >= items.size - PagedListViewModel.LOOKAHEAD) { if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.loadMore() } LaunchedEffect(index) { viewModel.loadMore() }