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(
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

View file

@ -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,23 +158,16 @@ 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 {
Text(
text = "New videos",
@ -190,18 +187,12 @@ 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,
)
FocusScrollRow(
videos = recentItems,
progressOf = { null },
onOpenVideo = onOpenVideo,
onLeftAtStart = onOpenDrawer,
) { index, video ->
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.recent.loadMore() }
}
@ -210,4 +201,49 @@ fun HomeRowsScreen(
}
}
}
/**
* 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 },
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 },
)
}
}

View file

@ -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),
)
}
}

View file

@ -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() }