diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index dca0a69..26f711a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -66,6 +66,7 @@ class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel 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, 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 diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index b0f5c39..baca2a6 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -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() } } } } } } -} \ No newline at end of file +} +/** + * 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, + 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) + } + } +} diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt index c4dd2d8..18af7ea 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -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 }, ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt index 7b99671..5c33ea0 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -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), ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index 8c41235..1aa8fc5 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -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() }