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.
This commit is contained in:
Jeena 2026-10-10 22:16:29 +00:00
parent 14cd3d3976
commit 9635e31589
4 changed files with 268 additions and 94 deletions

View file

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

View file

@ -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<String?>(null) } // handle
var playingVideo by rememberSaveable { mutableStateOf<String?>(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(
modifier = Modifier
.fillMaxSize()
// TV overscan safety margins (list content, not the player).
.padding(horizontal = 32.dp),
) {
VideoListScreen(
source = ListSource.Channel(channelDetails!!, channelDetails!!),
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch,
Column {
// SmartTube-style top bar: red round search + avatar opening the menu.
TopBar(
onSearchClick = { destination = Destination.Search; channelDetails = null },
onAccountClick = { drawerOpen = !drawerOpen },
)
}
} 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(
Box(
modifier = Modifier
.fillMaxSize()
// TV overscan safety margins on the content pane.
.padding(start = 8.dp, end = 32.dp, top = 16.dp),
.padding(start = 8.dp, end = 32.dp, top = 8.dp),
) {
when (destination) {
Destination.Home ->
HomeRowsScreen(
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch,
)
Destination.MyVideos ->
@ -178,6 +154,20 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
}
}
// 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(
shortUuid = playingVideo!!,
@ -185,10 +175,9 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
)
}
}
}
}
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
@ -197,3 +186,157 @@ private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector
Destination.Subscriptions -> Icons.Filled.Subscriptions
Destination.History -> Icons.Filled.History
}
/** 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,
)
}
}

View file

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

View file

@ -119,7 +119,7 @@ fun VideoListScreen(
}
LazyVerticalGrid(
columns = GridCells.Fixed(2),
columns = GridCells.Fixed(3),
state = gridState,
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
) {