home: Title bar with account and channel pages
The header-removal experiment went too far: the app now has a slim persistent title bar — logo + "JTube TV" on the left (it anchors the layout while the drawer is open), avatar + display name on the right as a purely visual indicator so it never steals D-pad focus. LEFT from the first card of every row opens the drawer (the subscriptions row silently skipped it, and the grid only wired the very first item), and UP no longer surprises by opening anything. The channel page that the Channels screen and the player's channel icon point at never actually rendered — ListSource.Channel, its repository call and the header state all existed without a UI. Videos of one channel now open as a grid with banner, avatar, name and handle; page headers (search field, channel banner) live inside the grid so they scroll along instead of stacking under the cards. Closes #7
This commit is contained in:
parent
a6dff3435d
commit
fdc570ac9e
5 changed files with 245 additions and 150 deletions
|
|
@ -13,6 +13,14 @@ Status vocabulary: `todo` · `doing` · `done`.
|
||||||
| 4 | Subscription videos | done | Verified incl. remote (federated) channels |
|
| 4 | Subscription videos | done | Verified incl. remote (federated) channels |
|
||||||
| 5 | Watch history (server) | done | read-only grid |
|
| 5 | Watch history (server) | done | read-only grid |
|
||||||
| 6 | Playback + report to server history | done | ExoPlayer HLS w/ `videoFileToken` for non-public media; watching reports every ~20 s verified server-side; resume position retrievable |
|
| 6 | Playback + report to server history | done | ExoPlayer HLS w/ `videoFileToken` for non-public media; watching reports every ~20 s verified server-side; resume position retrievable |
|
||||||
|
| 7 | Channel videos view (channel page) | done | Banner + avatar + name + video count header, paged video grid; opened from the Channels screen and the player's channel icon |
|
||||||
|
|
||||||
|
## Discussed but deferred (2026-10-12, while reworking the player controls)
|
||||||
|
|
||||||
|
- Like/dislike display: counts are in the video detail DTO; rating (own like via
|
||||||
|
`PUT /api/v1/videos/{uuid}/rating`) is a write op — display first, rating later.
|
||||||
|
- Subtitles: `GET /api/v1/videos/{uuid}/captions` → Media3 `SubtitleConfiguration`
|
||||||
|
on the player; language selection in the controls overlay.
|
||||||
|
|
||||||
## Next up (v0.2+)
|
## Next up (v0.2+)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,7 @@ class VideoListViewModel(
|
||||||
fun loadChannelHeader(repository: PeerTubeRepository) {
|
fun loadChannelHeader(repository: PeerTubeRepository) {
|
||||||
val s = source
|
val s = source
|
||||||
if (s is ListSource.Channel) {
|
if (s is ListSource.Channel) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { channelHeader = repository.videoChannel(s.handle).getOrNull() }
|
||||||
channelHeader = repository.videoChannel(s.handle).getOrNull()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,7 @@ fun HomeRowsScreen(
|
||||||
videos = viewModel.subItems,
|
videos = viewModel.subItems,
|
||||||
progressOf = { null },
|
progressOf = { null },
|
||||||
onOpenVideo = onOpenVideo,
|
onOpenVideo = onOpenVideo,
|
||||||
onLeftAtStart = { },
|
onLeftAtStart = onOpenDrawer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
package net.jeena.jtubetv.ui.screens
|
package net.jeena.jtubetv.ui.screens
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
|
@ -12,10 +12,13 @@ import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
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.AccountCircle
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
|
|
@ -36,14 +39,16 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
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.draw.clip
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
|
@ -51,6 +56,8 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import net.jeena.jtubetv.AppGraph
|
import net.jeena.jtubetv.AppGraph
|
||||||
|
import net.jeena.jtubetv.R
|
||||||
|
import net.jeena.jtubetv.data.PeerTubeRepository
|
||||||
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
|
||||||
|
|
@ -67,23 +74,28 @@ enum class Destination(val title: String) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home = SmartTube-style: hidden menu, red round search + avatar in a top bar,
|
* Home = SmartTube-style: persistent top bar with the app logo + title on the
|
||||||
* full-width rows/list content. The menu drawer slides in when wanted (avatar or
|
* left and the account button (avatar + display name, opens the menu) on the
|
||||||
* the search/BACK flows) and everything is key(epoch) so an account switch
|
* right; the menu drawer slides in below the bar so the logo/title stay in the
|
||||||
* rebuilds all state.
|
* same place whether the drawer is open or not. Content below 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()
|
||||||
|
val userProfile by viewModel.userProfile.collectAsState()
|
||||||
var destination by rememberSaveable { mutableStateOf(Destination.Home) }
|
var destination by rememberSaveable { mutableStateOf(Destination.Home) }
|
||||||
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
|
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
|
||||||
|
var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name
|
||||||
// NOT saveable: launching the app should never resume a half-watched player session.
|
// NOT saveable: launching the app should never resume a half-watched player session.
|
||||||
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) }
|
var drawerOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
var lastBackAt by rememberSaveable { mutableStateOf(0L) }
|
var lastBackAt by rememberSaveable { mutableStateOf(0L) }
|
||||||
val searchFocus = remember { FocusRequester() }
|
|
||||||
|
/** Close the drawer; focus falls back into the content pane. */
|
||||||
|
val dismissDrawer = { drawerOpen = false }
|
||||||
|
|
||||||
// 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).
|
||||||
|
|
@ -100,8 +112,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
// BACK order (TV standard): player (own handler) → drawer close → channel
|
// BACK order (TV standard): player (own handler) → drawer close → channel
|
||||||
// drilldown → non-Home destination → Home; from Home: double-press exits.
|
// drilldown → non-Home destination → Home; from Home: double-press exits.
|
||||||
BackHandler(enabled = drawerOpen && playingVideo == null) {
|
BackHandler(enabled = drawerOpen && playingVideo == null) {
|
||||||
drawerOpen = false
|
dismissDrawer()
|
||||||
runCatching { searchFocus.requestFocus() }
|
|
||||||
}
|
}
|
||||||
BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) {
|
BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) {
|
||||||
channelDetails = null
|
channelDetails = null
|
||||||
|
|
@ -133,35 +144,48 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
playingVideo == null
|
playingVideo == null
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
drawerOpen -> { drawerOpen = false; true }
|
drawerOpen -> { dismissDrawer(); true }
|
||||||
channelDetails != null -> { channelDetails = null; true }
|
channelDetails != null -> { channelDetails = null; true }
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
} else false
|
} else false
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
androidx.compose.runtime.key(epoch) {
|
|
||||||
Column {
|
Column {
|
||||||
// SmartTube-style top bar: red round search + avatar opening the menu.
|
// Persistent top bar: logo/title left, account button right.
|
||||||
TopBar(
|
TopBar(
|
||||||
onSearchClick = { destination = Destination.Search; channelDetails = null },
|
|
||||||
onAccountClick = { drawerOpen = !drawerOpen },
|
onAccountClick = { drawerOpen = !drawerOpen },
|
||||||
searchFocus = searchFocus,
|
profile = userProfile,
|
||||||
)
|
)
|
||||||
|
androidx.compose.runtime.key(epoch) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(
|
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 = 8.dp),
|
.padding(start = 8.dp, end = 32.dp, top = 8.dp),
|
||||||
) {
|
) {
|
||||||
when (destination) {
|
when {
|
||||||
|
// Channel videos page (opened from the Channels screen or the
|
||||||
|
// player's channel icon) overrides the destination content.
|
||||||
|
channelDetails != null ->
|
||||||
|
VideoListScreen(
|
||||||
|
source = ListSource.Channel(channelDetails!!, channelName ?: ""),
|
||||||
|
onOpenVideo = { playingVideo = it.shortUUID },
|
||||||
|
restoreToken = restoreToken,
|
||||||
|
epoch = epoch,
|
||||||
|
onOpenDrawer = { drawerOpen = true },
|
||||||
|
upToTopBar = { drawerOpen = true },
|
||||||
|
)
|
||||||
|
else -> when (destination) {
|
||||||
Destination.Home ->
|
Destination.Home ->
|
||||||
HomeRowsScreen(
|
HomeRowsScreen(
|
||||||
onOpenVideo = { playingVideo = it.shortUUID },
|
onOpenVideo = { playingVideo = it.shortUUID },
|
||||||
restoreToken = restoreToken,
|
restoreToken = restoreToken,
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
onOpenDrawer = { drawerOpen = true },
|
onOpenDrawer = { drawerOpen = true },
|
||||||
upToTopBar = { runCatching { searchFocus.requestFocus() } },
|
// UP over the first row opens the drawer.
|
||||||
|
upToTopBar = { drawerOpen = true },
|
||||||
)
|
)
|
||||||
Destination.MyVideos ->
|
Destination.MyVideos ->
|
||||||
VideoListScreen(
|
VideoListScreen(
|
||||||
|
|
@ -170,7 +194,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
restoreToken = restoreToken,
|
restoreToken = restoreToken,
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
onOpenDrawer = { drawerOpen = true },
|
onOpenDrawer = { drawerOpen = true },
|
||||||
upToTopBar = { runCatching { searchFocus.requestFocus() } },
|
upToTopBar = { drawerOpen = true },
|
||||||
)
|
)
|
||||||
Destination.Channels -> ChannelsScreen(
|
Destination.Channels -> ChannelsScreen(
|
||||||
onOpenChannel = { handle -> channelDetails = handle },
|
onOpenChannel = { handle -> channelDetails = handle },
|
||||||
|
|
@ -184,7 +208,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
restoreToken = restoreToken,
|
restoreToken = restoreToken,
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
onOpenDrawer = { drawerOpen = true },
|
onOpenDrawer = { drawerOpen = true },
|
||||||
upToTopBar = { runCatching { searchFocus.requestFocus() } },
|
upToTopBar = { drawerOpen = true },
|
||||||
)
|
)
|
||||||
Destination.Subscriptions ->
|
Destination.Subscriptions ->
|
||||||
VideoListScreen(
|
VideoListScreen(
|
||||||
|
|
@ -193,7 +217,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
restoreToken = restoreToken,
|
restoreToken = restoreToken,
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
onOpenDrawer = { drawerOpen = true },
|
onOpenDrawer = { drawerOpen = true },
|
||||||
upToTopBar = { runCatching { searchFocus.requestFocus() } },
|
upToTopBar = { drawerOpen = true },
|
||||||
)
|
)
|
||||||
Destination.History ->
|
Destination.History ->
|
||||||
VideoListScreen(
|
VideoListScreen(
|
||||||
|
|
@ -202,14 +226,13 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
restoreToken = restoreToken,
|
restoreToken = restoreToken,
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
onOpenDrawer = { drawerOpen = true },
|
onOpenDrawer = { drawerOpen = true },
|
||||||
upToTopBar = { runCatching { searchFocus.requestFocus() } },
|
upToTopBar = { drawerOpen = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Slide-in menu drawer (SmartTube style: hidden until wanted).
|
// Slide-in menu drawer, below the top bar so logo/title stay put.
|
||||||
if (drawerOpen) {
|
if (drawerOpen) {
|
||||||
MenuDrawer(
|
MenuDrawer(
|
||||||
selected = destination,
|
selected = destination,
|
||||||
|
|
@ -219,14 +242,22 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
drawerOpen = false
|
drawerOpen = false
|
||||||
},
|
},
|
||||||
onSignOut = { viewModel.logout() },
|
onSignOut = { viewModel.logout() },
|
||||||
onDismiss = { drawerOpen = false },
|
onDismiss = { dismissDrawer() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (playingVideo != null) {
|
if (playingVideo != null) {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
shortUuid = playingVideo!!,
|
shortUuid = playingVideo!!,
|
||||||
onBack = { playingVideo = null },
|
onBack = { playingVideo = null },
|
||||||
|
onOpenChannel = { handle, displayName ->
|
||||||
|
playingVideo = null
|
||||||
|
channelDetails = handle
|
||||||
|
channelName = displayName
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -242,60 +273,99 @@ private fun Destination.icon(): ImageVector =
|
||||||
Destination.History -> Icons.Filled.History
|
Destination.History -> Icons.Filled.History
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top bar: red round search + account avatar (opens the menu), SmartTube-style. */
|
/**
|
||||||
|
* Persistent top bar: logo + "JTube TV" on the left (unchanged while the drawer
|
||||||
|
* is open, so they anchor the layout) and the account button on the right.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun TopBar(
|
private fun TopBar(
|
||||||
onSearchClick: () -> Unit,
|
|
||||||
onAccountClick: () -> Unit,
|
onAccountClick: () -> Unit,
|
||||||
searchFocus: FocusRequester,
|
profile: PeerTubeRepository.UserProfile?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 24.dp, vertical = 10.dp),
|
.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp),
|
||||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
RoundTopButton(
|
// The launcher icon is an adaptive icon, which Compose can't paint
|
||||||
bg = Color(0xFFCE2B2B),
|
// directly — the launcher look is its two layers: dark background +
|
||||||
icon = Icons.Filled.Search,
|
// foreground glyph (both vector drawables).
|
||||||
desc = "Search",
|
// Bottom-aligned to the text line and nudged up so the logo's bottom
|
||||||
onClick = onSearchClick,
|
// edge sits on the text baseline, not below it.
|
||||||
focusRequester = searchFocus,
|
Box(
|
||||||
)
|
|
||||||
// "Who is logged in" — account button: avatar circle + display name (SmartTube).
|
|
||||||
val accountFocused = remember { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.onFocusChanged { accountFocused.value = it.isFocused }
|
.align(Alignment.Bottom)
|
||||||
.background(Color.Transparent)
|
.padding(bottom = 17.dp) // slightly below the text baseline
|
||||||
.then(
|
.size(32.dp) // match the avatar row so both headers align
|
||||||
if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape)
|
.clip(RoundedCornerShape(8.dp))
|
||||||
else Modifier
|
.background(Color(0xFF101010)),
|
||||||
|
) {
|
||||||
|
// Zoom past the adaptive-icon safe zone: the glyph is only ~45% of
|
||||||
|
// the 108dp viewport, so Fit would render it tiny.
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.icon_foreground),
|
||||||
|
contentDescription = "JTube TV logo",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.scale(2.1f),
|
||||||
)
|
)
|
||||||
.focusable()
|
}
|
||||||
.dpadCenterActivatable(onAccountClick)
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
.clickable(onClick = onAccountClick),
|
Text(
|
||||||
|
text = "JTube TV",
|
||||||
|
color = Color.White,
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
// Account indicator: avatar + display name. Purely visual on TV (D-pad
|
||||||
|
// focus and activation stay in the content/drawer); a click (mouse/touch)
|
||||||
|
// still opens the menu drawer.
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.clickable(onClick = onAccountClick),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
|
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
val avatarUrl = AppGraph.tokenStore.userAvatarUrl
|
AccountAvatar(
|
||||||
if (avatarUrl != null) {
|
url = profile?.avatarUrl ?: AppGraph.tokenStore.userAvatarUrl,
|
||||||
|
size = 40.dp,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = profile?.displayName
|
||||||
|
?: AppGraph.tokenStore.userDisplayName
|
||||||
|
?: AppGraph.tokenStore.username?.substringBefore('@')
|
||||||
|
?: "Account",
|
||||||
|
color = Color(0xFFD0D0D0),
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
// keep the button compact; the bar never wraps under a long name
|
||||||
|
.widthIn(max = 160.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
private fun AccountAvatar(url: String?, size: androidx.compose.ui.unit.Dp) {
|
||||||
|
if (url != null) {
|
||||||
coil.compose.AsyncImage(
|
coil.compose.AsyncImage(
|
||||||
model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
||||||
.data(avatarUrl)
|
.data(url)
|
||||||
.size(96)
|
.size(96)
|
||||||
.build(),
|
.build(),
|
||||||
contentDescription = "Account",
|
contentDescription = "Account",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(44.dp)
|
.size(size)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color(0xFF262626)),
|
.background(Color(0xFF262626)),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(44.dp)
|
.size(size)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color(0xFF262626)),
|
.background(Color(0xFF262626)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
|
|
@ -307,56 +377,6 @@ private fun TopBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
text = AppGraph.tokenStore.userDisplayName
|
|
||||||
?: AppGraph.tokenStore.username?.substringBefore('@') ?: "Account",
|
|
||||||
color = Color(0xFFB0B0B0),
|
|
||||||
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Text(
|
|
||||||
text = "JTube TV",
|
|
||||||
color = Color(0xFF9A9A9A),
|
|
||||||
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.align(Alignment.CenterVertically),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RoundTopButton(
|
|
||||||
bg: Color,
|
|
||||||
icon: ImageVector,
|
|
||||||
desc: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
focusRequester: FocusRequester? = null,
|
|
||||||
) {
|
|
||||||
var focused by remember { mutableStateOf(false) }
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.onFocusChanged { focused = it.isFocused }
|
|
||||||
.background(bg)
|
|
||||||
.then(
|
|
||||||
if (focused) Modifier.border(
|
|
||||||
3.dp, Color.White, CircleShape
|
|
||||||
) else Modifier
|
|
||||||
)
|
|
||||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
|
||||||
.focusable()
|
|
||||||
.dpadCenterActivatable(onClick)
|
|
||||||
.clickable(onClick = onClick),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = desc,
|
|
||||||
tint = Color.White,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -381,7 +401,9 @@ private fun MenuDrawer(
|
||||||
.width(240.dp)
|
.width(240.dp)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFF111111))
|
.background(Color(0xFF111111))
|
||||||
.padding(vertical = 24.dp)
|
// Logo/title live in the persistent top bar above; only a slim
|
||||||
|
// top gap here so the item list starts below the bar.
|
||||||
|
.padding(top = 8.dp, bottom = 24.dp)
|
||||||
.onPreviewKeyEvent { ke ->
|
.onPreviewKeyEvent { ke ->
|
||||||
// RIGHT from the drawer closes it; focus returns to the top bar.
|
// RIGHT from the drawer closes it; focus returns to the top bar.
|
||||||
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
|
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
|
||||||
|
|
@ -392,12 +414,6 @@ private fun MenuDrawer(
|
||||||
} else false
|
} else false
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
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 ->
|
Destination.entries.forEachIndexed { index, dest ->
|
||||||
MenuDrawerItem(
|
MenuDrawerItem(
|
||||||
icon = dest.icon(),
|
icon = dest.icon(),
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ package net.jeena.jtubetv.ui.screens.list
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
|
|
@ -15,21 +18,24 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
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.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import net.jeena.jtubetv.AppGraph
|
||||||
import net.jeena.jtubetv.R
|
import net.jeena.jtubetv.R
|
||||||
import net.jeena.jtubetv.data.api.VideoDto
|
import net.jeena.jtubetv.data.api.VideoDto
|
||||||
import net.jeena.jtubetv.ui.list.PagedListViewModel
|
import net.jeena.jtubetv.ui.list.PagedListViewModel
|
||||||
|
|
@ -80,9 +86,19 @@ fun VideoListScreen(
|
||||||
key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver,
|
key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver,
|
||||||
) { androidx.compose.foundation.lazy.grid.LazyGridState() }
|
) { androidx.compose.foundation.lazy.grid.LazyGridState() }
|
||||||
|
|
||||||
when (source) {
|
if (source is ListSource.Channel) {
|
||||||
is ListSource.Search -> SearchHeader(viewModel)
|
// Fetch the banner/avatar/counts for the channel page header.
|
||||||
else -> {}
|
LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cards-per-row of the adaptive grid — LEFT from the first card of ANY row
|
||||||
|
// must open the drawer (issue #7: "every time I'm all the way on the left").
|
||||||
|
val cellsPerRow by remember {
|
||||||
|
androidx.compose.runtime.derivedStateOf {
|
||||||
|
val info = gridState.layoutInfo
|
||||||
|
val w = info.visibleItemsInfo.firstOrNull()?.size?.width ?: 0
|
||||||
|
if (w > 0) (info.viewportSize.width / w).coerceIn(1, 10) else 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewModel.loadingInitial && items.isEmpty()) {
|
if (viewModel.loadingInitial && items.isEmpty()) {
|
||||||
|
|
@ -127,6 +143,15 @@ fun VideoListScreen(
|
||||||
state = gridState,
|
state = gridState,
|
||||||
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
|
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
|
// Page headers (search field / channel banner) live INSIDE the grid so
|
||||||
|
// they scroll with the content and never stack under the cards.
|
||||||
|
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
||||||
|
when (source) {
|
||||||
|
is ListSource.Search -> SearchHeader(viewModel)
|
||||||
|
is ListSource.Channel -> ChannelHeader(channelHeader = viewModel.channelHeader)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
|
itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
|
||||||
val video: VideoDto = when (item) {
|
val video: VideoDto = when (item) {
|
||||||
is VideoListItem.HistoryEntry -> item.history.video
|
is VideoListItem.HistoryEntry -> item.history.video
|
||||||
|
|
@ -140,7 +165,7 @@ fun VideoListScreen(
|
||||||
onClick = { lastOpenedIndex = index; onOpenVideo(video) },
|
onClick = { lastOpenedIndex = index; onOpenVideo(video) },
|
||||||
showPrivacy = source is ListSource.MyVideos,
|
showPrivacy = source is ListSource.MyVideos,
|
||||||
compact = true,
|
compact = true,
|
||||||
onLeftAtStart = if (index == 0) onOpenDrawer else null,
|
onLeftAtStart = if (index % cellsPerRow.coerceAtLeast(1) == 0) onOpenDrawer else null,
|
||||||
onUpToTopBar = upToTopBar?.takeIf { index < 4 },
|
onUpToTopBar = upToTopBar?.takeIf { index < 4 },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(fr),
|
.focusRequester(fr),
|
||||||
|
|
@ -194,6 +219,54 @@ private fun SearchHeader(viewModel: VideoListViewModel) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Channel page header: banner strip, then avatar + display name + handle + counts. */
|
||||||
|
@Composable
|
||||||
|
private fun ChannelHeader(channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?) {
|
||||||
|
if (channelHeader == null) return
|
||||||
|
androidx.compose.foundation.layout.Column {
|
||||||
|
val banner = channelHeader.banners.maxByOrNull { it.width }
|
||||||
|
if (banner != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp)
|
||||||
|
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
val avatar = channelHeader.avatars.maxByOrNull { it.width }
|
||||||
|
if (avatar != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 16.dp, top = 10.dp)
|
||||||
|
.size(52.dp)
|
||||||
|
.clip(androidx.compose.foundation.shape.CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.padding(start = 16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = channelHeader.displayName,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = (if (channelHeader.videosCount > 0) channelHeader.videosCount.toString() + " videos · " else "") +
|
||||||
|
channelHeader.name + "@" + channelHeader.host,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun itemStableKey(item: VideoListItem): String =
|
private fun itemStableKey(item: VideoListItem): String =
|
||||||
when (item) {
|
when (item) {
|
||||||
is VideoListItem.Video -> "v" + item.video.id
|
is VideoListItem.Video -> "v" + item.video.id
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue