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.list.PagedListViewModel
import net.jeena.jtubetv.ui.screens.list.VideoCard 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). */ /** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */
data class ContinueItem(val video: VideoDto, val progress: Float?) data class ContinueItem(val video: VideoDto, val progress: Float?)
@ -157,9 +162,10 @@ fun HomeRowsScreen(
VideoCard( VideoCard(
video = item.video, video = item.video,
onClick = { onOpenVideo(item.video) }, onClick = { onOpenVideo(item.video) },
modifier = Modifier.width(ROW_CARD_WIDTH), modifier = Modifier.width(rowCardWidth()),
showPrivacy = false, showPrivacy = false,
progress = item.progress, progress = item.progress,
compact = true,
) )
} }
} }
@ -192,8 +198,9 @@ fun HomeRowsScreen(
VideoCard( VideoCard(
video = video, video = video,
onClick = { onOpenVideo(video) }, onClick = { onOpenVideo(video) },
modifier = Modifier.width(ROW_CARD_WIDTH), modifier = Modifier.width(rowCardWidth()),
showPrivacy = false, showPrivacy = false,
compact = true,
) )
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.recent.loadMore() } LaunchedEffect(index) { viewModel.recent.loadMore() }

View file

@ -1,38 +1,51 @@
package net.jeena.jtubetv.ui.screens 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Logout 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.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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
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.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.compose.ui.unit.dp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.NavigationDrawer
import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.Text import androidx.tv.material3.Text
import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.data.api.VideoDto
import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.AppViewModel
import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.list.ListSource
import net.jeena.jtubetv.ui.screens.list.VideoListScreen import net.jeena.jtubetv.ui.screens.list.VideoListScreen
import androidx.activity.compose.BackHandler
enum class Destination(val title: String) { enum class Destination(val title: String) {
Home("Home"), Home("Home"),
@ -43,9 +56,12 @@ enum class Destination(val title: String) {
History("History"), 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 * Home = SmartTube-style: hidden menu, red round search + avatar in a top bar,
* focused card survive BACK (couch users don't want to re-navigate from the top). */ * 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 @Composable
fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) {
val epoch by viewModel.sessionEpoch.collectAsState() 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 channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends 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 // 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). // 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++ if (playingVideo == null && restoreToken >= 1) restoreToken++
} }
// Channel drilldown hides the sidebar; remote BACK must return to the channels list. // BACK order: player (own BackHandler) → drawer → channel drilldown → exit guard.
BackHandler(enabled = playingVideo == null && channelDetails != null) { 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 channelDetails = null
} }
BackHandler(enabled = drawerOpen && playingVideo == null) {
drawerOpen = false
}
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
androidx.compose.runtime.key(epoch) { androidx.compose.runtime.key(epoch) {
if (destination == Destination.Channels && channelDetails != null) { Column {
androidx.compose.foundation.layout.Box( // SmartTube-style top bar: red round search + avatar opening the menu.
TopBar(
onSearchClick = { destination = Destination.Search; channelDetails = null },
onAccountClick = { drawerOpen = !drawerOpen },
)
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// TV overscan safety margins (list content, not the player). // TV overscan safety margins on the content pane.
.padding(horizontal = 32.dp), .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) { when (destination) {
Destination.Home -> Destination.Home ->
HomeRowsScreen( HomeRowsScreen(
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
) )
Destination.MyVideos -> Destination.MyVideos ->
@ -174,9 +150,23 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
epoch = epoch, 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) { if (playingVideo != null) {
PlayerScreen( PlayerScreen(
@ -184,11 +174,10 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
onBack = { playingVideo = null }, onBack = { playingVideo = null },
) )
} }
}
} }
} }
private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector = private fun Destination.icon(): ImageVector =
when (this) { when (this) {
Destination.Home -> Icons.Filled.VideoLibrary Destination.Home -> Icons.Filled.VideoLibrary
Destination.MyVideos -> Icons.Filled.PersonalVideo 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.Search -> Icons.Filled.Search
Destination.Subscriptions -> Icons.Filled.Subscriptions Destination.Subscriptions -> Icons.Filled.Subscriptions
Destination.History -> Icons.Filled.History 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.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth 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.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import coil.compose.AsyncImage import coil.compose.AsyncImage
@ -43,19 +46,35 @@ fun VideoCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showPrivacy: Boolean = true, showPrivacy: Boolean = true,
progress: Float? = null, progress: Float? = null,
/** Compact = row in the SmartTube home: single-line title, smaller. */
compact: Boolean = false,
) { ) {
Card( Card(
onClick = onClick, onClick = onClick,
modifier = modifier modifier = modifier.padding(6.dp),
.fillMaxWidth() colors = CardDefaults.colors(
.height(268.dp) containerColor = UnfocusedCardBg,
.padding(6.dp), contentColor = Color.White,
colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), // 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(158.dp) .aspectRatio(16f / 9f)
.background(Color(0xFF222222)), .background(Color(0xFF222222)),
) { ) {
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
@ -105,10 +124,11 @@ fun VideoCard(
Text( Text(
text = video.name, text = video.name,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 2, maxLines = if (compact) 1 else 2,
minLines = 2, minLines = if (compact) 1 else 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically, 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 = internal fun formatViews(views: Long): String =
when { when {
views >= 1_000_000 -> "%.1fM views".format(Locale.US, views / 1_000_000.0) views >= 1_000_000 -> "%.1fM views".format(Locale.US, views / 1_000_000.0)

View file

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