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:
parent
14cd3d3976
commit
9635e31589
4 changed files with 268 additions and 94 deletions
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
modifier = Modifier
|
TopBar(
|
||||||
.fillMaxSize()
|
onSearchClick = { destination = Destination.Search; channelDetails = null },
|
||||||
// TV overscan safety margins (list content, not the player).
|
onAccountClick = { drawerOpen = !drawerOpen },
|
||||||
.padding(horizontal = 32.dp),
|
|
||||||
) {
|
|
||||||
VideoListScreen(
|
|
||||||
source = ListSource.Channel(channelDetails!!, channelDetails!!),
|
|
||||||
onOpenVideo = { playingVideo = it.shortUUID },
|
|
||||||
restoreToken = restoreToken,
|
|
||||||
epoch = epoch,
|
|
||||||
)
|
)
|
||||||
}
|
Box(
|
||||||
} 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
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// TV overscan safety margins on the content pane.
|
// 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) {
|
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 ->
|
||||||
|
|
@ -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) {
|
if (playingVideo != null) {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
shortUuid = playingVideo!!,
|
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) {
|
when (this) {
|
||||||
Destination.Home -> Icons.Filled.VideoLibrary
|
Destination.Home -> Icons.Filled.VideoLibrary
|
||||||
Destination.MyVideos -> Icons.Filled.PersonalVideo
|
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.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue