From 9635e31589cd61a1c622f085065f3a6a5906154b Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 22:16:29 +0000 Subject: [PATCH] ui: SmartTube layout - hidden rail, top bar, white focus cards Drop the always-visible sidebar: content is now full width so ~4.5 compact cards fit per row (width derived from the screen). A slide-in menu drawer opens via the avatar (BACK/LEFT close it) and carries the destinations + sign out with legible labels (#B0B0B0) replacing the illegible dark-grey rail. Cards no longer scale on focus: they flip to a white background with dark text and a white frame like SmartTube. Grids move to 3 columns with the freed width. --- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 13 +- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 307 +++++++++++++----- .../jtubetv/ui/screens/list/VideoCard.kt | 40 ++- .../ui/screens/list/VideoListScreen.kt | 2 +- 4 files changed, 268 insertions(+), 94 deletions(-) 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 a4dc174..b0f5c39 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 @@ -36,7 +36,12 @@ import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.list.PagedListViewModel import net.jeena.jtubetv.ui.screens.list.VideoCard -private val ROW_CARD_WIDTH = 296.dp +/** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */ +@Composable +private fun rowCardWidth(): androidx.compose.ui.unit.Dp { + val windowWidth = androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp + return ((windowWidth / 4.35f) - 10).dp +} /** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */ data class ContinueItem(val video: VideoDto, val progress: Float?) @@ -157,9 +162,10 @@ fun HomeRowsScreen( VideoCard( video = item.video, onClick = { onOpenVideo(item.video) }, - modifier = Modifier.width(ROW_CARD_WIDTH), + modifier = Modifier.width(rowCardWidth()), showPrivacy = false, progress = item.progress, + compact = true, ) } } @@ -192,8 +198,9 @@ fun HomeRowsScreen( VideoCard( video = video, onClick = { onOpenVideo(video) }, - modifier = Modifier.width(ROW_CARD_WIDTH), + modifier = Modifier.width(rowCardWidth()), showPrivacy = false, + compact = true, ) if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.recent.loadMore() } 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 9bcf9a6..c4dd2d8 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 @@ -1,38 +1,51 @@ package net.jeena.jtubetv.ui.screens +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Logout -import androidx.compose.material.icons.filled.Subscriptions -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material.icons.filled.PersonalVideo +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Subscriptions +import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable 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.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon -import androidx.tv.material3.NavigationDrawer -import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.Text import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.screens.list.VideoListScreen -import androidx.activity.compose.BackHandler enum class Destination(val title: String) { Home("Home"), @@ -43,9 +56,12 @@ enum class Destination(val title: String) { History("History"), } -/** Home = sidebar (D-pad navigable) + content pane. The player renders as a - * fullscreen overlay: the list beneath stays composed, so its scroll position and - * focused card survive BACK (couch users don't want to re-navigate from the top). */ +/** + * Home = SmartTube-style: hidden menu, red round search + avatar in a top bar, + * full-width rows/list content. The menu drawer slides in when wanted (avatar or + * the search/BACK flows) and everything is key(epoch) so an account switch + * rebuilds all state. + */ @Composable fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { val epoch by viewModel.sessionEpoch.collectAsState() @@ -53,6 +69,9 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi var channelDetails by rememberSaveable { mutableStateOf(null) } // handle var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends + var drawerOpen by rememberSaveable { mutableStateOf(false) } + val context = androidx.compose.ui.platform.LocalContext.current + var lastBackAt by rememberSaveable { mutableStateOf(0L) } // After BACK from the player: bump the restore token so lists re-focus the opened // card and re-fetch positions (UX report BUG-3 + BUG-7). @@ -60,85 +79,42 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi if (playingVideo == null && restoreToken >= 1) restoreToken++ } - // Channel drilldown hides the sidebar; remote BACK must return to the channels list. - BackHandler(enabled = playingVideo == null && channelDetails != null) { + // BACK order: player (own BackHandler) → drawer → channel drilldown → exit guard. + BackHandler(enabled = playingVideo == null && channelDetails == null && !drawerOpen) { + val now = System.currentTimeMillis() + if (now - lastBackAt < 2000) { + (context as? android.app.Activity)?.finish() + } else { + lastBackAt = now + android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() + } + } + BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) { channelDetails = null } + BackHandler(enabled = drawerOpen && playingVideo == null) { + drawerOpen = false + } Box(modifier = Modifier.fillMaxSize()) { androidx.compose.runtime.key(epoch) { - if (destination == Destination.Channels && channelDetails != null) { - androidx.compose.foundation.layout.Box( + Column { + // SmartTube-style top bar: red round search + avatar opening the menu. + TopBar( + onSearchClick = { destination = Destination.Search; channelDetails = null }, + onAccountClick = { drawerOpen = !drawerOpen }, + ) + Box( modifier = Modifier .fillMaxSize() - // TV overscan safety margins (list content, not the player). - .padding(horizontal = 32.dp), + // TV overscan safety margins on the content pane. + .padding(start = 8.dp, end = 32.dp, top = 8.dp), ) { - VideoListScreen( - source = ListSource.Channel(channelDetails!!, channelDetails!!), - onOpenVideo = { playingVideo = it.shortUUID }, - restoreToken = restoreToken, - epoch = epoch, - ) - } - } else { - NavigationDrawer(drawerContent = { - androidx.compose.foundation.layout.Column( - modifier = Modifier.width(220.dp).padding(vertical = 16.dp), - ) { - Text( - text = "JTube TV", - modifier = Modifier.padding(start = 28.dp, bottom = 16.dp), - style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, - color = androidx.compose.ui.graphics.Color.White, - ) - Destination.entries.forEach { dest -> - NavigationDrawerItem( - selected = dest == destination, - onClick = { - destination = dest - channelDetails = null - }, - leadingContent = { - Icon(imageVector = dest.icon(), contentDescription = null) - }, - content = { Text(dest.title) }, - ) - } - val signOutContext = androidx.compose.ui.platform.LocalContext.current - var lastSignOutTap by remember { mutableStateOf(0L) } - NavigationDrawerItem( - selected = false, - onClick = { - val now = System.currentTimeMillis() - if (now - lastSignOutTap < 3000) { - viewModel.logout() - } else { - lastSignOutTap = now - android.widget.Toast.makeText( - signOutContext, - "Press again to sign out", - android.widget.Toast.LENGTH_SHORT, - ).show() - } - }, - leadingContent = { - Icon(imageVector = Icons.Filled.Logout, contentDescription = null) - }, - content = { Text("Sign out") }, - ) - } - }) { - androidx.compose.foundation.layout.Box( - modifier = Modifier - .fillMaxSize() - // TV overscan safety margins on the content pane. - .padding(start = 8.dp, end = 32.dp, top = 16.dp), - ) { when (destination) { Destination.Home -> HomeRowsScreen( onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, epoch = epoch, ) Destination.MyVideos -> @@ -174,9 +150,23 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi epoch = epoch, ) } - } } } + } + + // Slide-in menu drawer (SmartTube style: hidden until wanted). + if (drawerOpen) { + MenuDrawer( + selected = destination, + onSelect = { + destination = it + channelDetails = null + drawerOpen = false + }, + onSignOut = { viewModel.logout() }, + onDismiss = { drawerOpen = false }, + ) + } if (playingVideo != null) { PlayerScreen( @@ -184,11 +174,10 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onBack = { playingVideo = null }, ) } - } } } -private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector = +private fun Destination.icon(): ImageVector = when (this) { Destination.Home -> Icons.Filled.VideoLibrary Destination.MyVideos -> Icons.Filled.PersonalVideo @@ -196,4 +185,158 @@ private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector Destination.Search -> Icons.Filled.Search Destination.Subscriptions -> Icons.Filled.Subscriptions Destination.History -> Icons.Filled.History - } \ No newline at end of file + } + +/** Top bar: red round search + account avatar (opens the menu), SmartTube-style. */ +@Composable +private fun TopBar( + onSearchClick: () -> Unit, + onAccountClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 10.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp), + ) { + val searchFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } } + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFFCE2B2B)) + .focusRequester(searchFocus) + .focusable() + .clickable(onClick = onSearchClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Search", + tint = Color.White, + ) + } + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFF262626)) + .focusable() + .clickable(onClick = onAccountClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.PersonalVideo, + contentDescription = "Menu", + tint = Color(0xFFCCCCCC), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "JTube TV", + color = Color(0xFF9A9A9A), + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } +} + +/** + * Slide-in menu drawer: destination list + sign out, readable on a dark background + * (UX report: unselected grey was illegible). Selected item gets the red accent. + */ +@Composable +private fun MenuDrawer( + selected: Destination, + onSelect: (Destination) -> Unit, + onSignOut: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val drawerFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { drawerFocus.requestFocus() } } + val signOutContext = androidx.compose.ui.platform.LocalContext.current + var lastSignOutTap by remember { mutableStateOf(0L) } + Row(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .width(240.dp) + .fillMaxSize() + .background(Color(0xFF111111)) + .padding(vertical = 24.dp), + ) { + Text( + text = "JTube TV", + color = Color.White, + style = androidx.compose.material3.MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(start = 28.dp, bottom = 20.dp), + ) + Destination.entries.forEachIndexed { index, dest -> + MenuDrawerItem( + icon = dest.icon(), + label = dest.title, + selected = dest == selected, + onClick = { onSelect(dest) }, + modifier = if (index == 0) Modifier.focusRequester(drawerFocus) else Modifier, + ) + } + Spacer(modifier = Modifier.weight(1f)) + MenuDrawerItem( + icon = Icons.Filled.Logout, + label = "Sign out", + selected = false, + onClick = { + if (System.currentTimeMillis() - lastSignOutTap < 3000) { + onSignOut() + } else { + lastSignOutTap = System.currentTimeMillis() + android.widget.Toast.makeText( + signOutContext, + "Press again to sign out", + android.widget.Toast.LENGTH_SHORT, + ).show() + } + }, + modifier = Modifier, + ) + } + // tap/click outside closes; focus LEFT from the drawer also closes + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .focusable() + .pointerInput(Unit) { detectTapGestures { onDismiss() } }, + ) + } +} + +@Composable +private fun MenuDrawerItem( + icon: ImageVector, + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val labelColor = if (selected) Color(0xFFE0E0E0) else Color(0xFFB0B0B0) + Row( + modifier = modifier + .fillMaxWidth() + .then(if (selected) Modifier.background(Color(0x33CE2B2B)) else Modifier) + .focusable() + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp), + ) { + Icon(imageVector = icon, contentDescription = null, tint = labelColor) + Text( + text = label, + color = labelColor, + style = androidx.compose.material3.MaterialTheme.typography.bodyLarge, + ) + } +} \ No newline at end of file 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 8d67b29..7b99671 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 @@ -3,6 +3,7 @@ package net.jeena.jtubetv.ui.screens.list import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -19,6 +20,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.foundation.BorderStroke +import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage @@ -43,19 +46,35 @@ fun VideoCard( modifier: Modifier = Modifier, showPrivacy: Boolean = true, progress: Float? = null, + /** Compact = row in the SmartTube home: single-line title, smaller. */ + compact: Boolean = false, ) { Card( onClick = onClick, - modifier = modifier - .fillMaxWidth() - .height(268.dp) - .padding(6.dp), - colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), + modifier = modifier.padding(6.dp), + colors = CardDefaults.colors( + containerColor = UnfocusedCardBg, + contentColor = Color.White, + // SmartTube look: focused card turns white with dark text, no size change. + focusedContainerColor = Color.White, + focusedContentColor = Color(0xFF101010), + ), + scale = CardDefaults.scale(focusedScale = 1f), + border = CardDefaults.border( + focusedBorder = Border( + border = BorderStroke(3.dp, Color.White), + shape = ContainerShape, + ), + pressedBorder = Border( + border = BorderStroke(3.dp, Color.White), + shape = ContainerShape, + ), + ), ) { Box( modifier = Modifier .fillMaxWidth() - .height(158.dp) + .aspectRatio(16f / 9f) .background(Color(0xFF222222)), ) { video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> @@ -105,10 +124,11 @@ fun VideoCard( Text( text = video.name, style = MaterialTheme.typography.titleSmall, - maxLines = 2, - minLines = 2, + maxLines = if (compact) 1 else 2, + minLines = if (compact) 1 else 2, overflow = TextOverflow.Ellipsis, ) + Row( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, @@ -161,6 +181,10 @@ internal fun formatDuration(seconds: Int): String { } } +/** Card palette (SmartTube-style): near-black idle, white + dark text on focus. */ +internal val UnfocusedCardBg = Color(0xFF171717) +internal val ContainerShape = RoundedCornerShape(10.dp) + internal fun formatViews(views: Long): String = when { views >= 1_000_000 -> "%.1fM views".format(Locale.US, views / 1_000_000.0) 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 7adcbce..8c41235 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 @@ -119,7 +119,7 @@ fun VideoListScreen( } LazyVerticalGrid( - columns = GridCells.Fixed(2), + columns = GridCells.Fixed(3), state = gridState, modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) {