channel: redesign the header, reachable Subscribe button (#27)
Without a mouse the channel banner was invisible: opening a channel focused the first video card, which scrolled the header (a grid item) off-screen. The header is now pinned above the grid and never scrolls away. The banner is its backdrop, darkened to black at the bottom; the avatar sits beside the title with a "by <owner> on <platform>" byline, the subscriber/video/created stats, and the description flowing into the black beneath (a long one grows the header down instead of being clipped). A Subscribe button sits on the banner, reachable with UP from the first card, wired to the same follow/unfollow as the player. AGENTS.md: subscribing is in scope (already on the player, now the channel page too), so drop it from the excluded list.
This commit is contained in:
parent
b2af6fc1a5
commit
21d8290f1d
2 changed files with 220 additions and 88 deletions
|
|
@ -1,8 +1,11 @@
|
|||
package net.jeena.jtubetv.ui.screens.list
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.VideoLibrary
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -159,22 +162,29 @@ fun VideoListScreen(
|
|||
return
|
||||
}
|
||||
|
||||
androidx.compose.foundation.layout.Column(Modifier.fillMaxSize()) {
|
||||
// Channel page: the header is PINNED above the grid (issue #27) rather than
|
||||
// scrolling away as the first grid item — auto-focusing the first video card
|
||||
// no longer hides the banner, and the Subscribe button on it stays reachable.
|
||||
if (source is ListSource.Channel) {
|
||||
ChannelHeader(
|
||||
channelHeader = viewModel.channelHeader,
|
||||
videoTotal = viewModel.videoTotal,
|
||||
handle = source.handle,
|
||||
onLeftEdge = onOpenDrawer,
|
||||
)
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
// Same card size as the Home rows: ~4 fit per row (user request).
|
||||
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
|
||||
state = gridState,
|
||||
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().weight(1f).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, onOpenDrawer)
|
||||
is ListSource.Channel -> ChannelHeader(
|
||||
channelHeader = viewModel.channelHeader,
|
||||
videoTotal = viewModel.videoTotal,
|
||||
)
|
||||
else -> {}
|
||||
// Search field still scrolls with its results (its own header item);
|
||||
// the channel banner is pinned above the grid instead (issue #27).
|
||||
if (source is ListSource.Search) {
|
||||
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
||||
SearchHeader(viewModel, onOpenDrawer)
|
||||
}
|
||||
}
|
||||
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
|
||||
|
|
@ -393,95 +403,216 @@ private fun ScopeChip(
|
|||
}
|
||||
}
|
||||
|
||||
/** Channel page header: banner strip, then avatar + display name + handle,
|
||||
* a metadata strip (subscribers/videos/created + owner, issue #14) and the
|
||||
* channel description snippet above the videos. */
|
||||
/**
|
||||
* Channel page header (issue #27): the banner is the backdrop of a fixed-height
|
||||
* header, darkened with a bottom-up gradient, with the channel identity + stats
|
||||
* and a reachable Subscribe button laid over it. It's pinned above the grid so
|
||||
* it never scrolls away, and the video cards start directly beneath it.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChannelHeader(
|
||||
channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?,
|
||||
videoTotal: Long = 0,
|
||||
handle: String,
|
||||
onLeftEdge: () -> Unit = {},
|
||||
) {
|
||||
if (channelHeader == null) return
|
||||
val metaColor = Color(0xFF9A9A9A)
|
||||
androidx.compose.foundation.layout.Column {
|
||||
val banner = channelHeader.banners.maxByOrNull { it.width }
|
||||
val metaColor = Color(0xFFCFCFCF)
|
||||
val banner = channelHeader.banners.maxByOrNull { it.width }
|
||||
val description = channelHeader.description?.takeIf { it.isNotBlank() }
|
||||
androidx.compose.foundation.layout.Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, top = 4.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
|
||||
.background(Color.Black),
|
||||
) {
|
||||
// Banner area: a fixed height with the identity + stats anchored to its
|
||||
// bottom. A long description flows into the black strip BELOW it rather than
|
||||
// being pushed up over the banner or clipped (user request).
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(178.dp)
|
||||
.background(Color(0xFF1C1C1C)),
|
||||
) {
|
||||
if (banner != null) {
|
||||
AsyncImage(
|
||||
ShimmerAsyncImage(
|
||||
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)),
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
}
|
||||
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 = channelHeader.name + "@" + channelHeader.host,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Metadata strip between the identity and the videos (issue #14): the
|
||||
// counts SmartTube shows, plus creation date and the owning user.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp),
|
||||
// Darkening overlay, fading to solid black at the bottom so the banner
|
||||
// blends seamlessly into the description strip beneath it.
|
||||
Box(
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||
0f to Color(0x33000000),
|
||||
0.5f to Color(0x99000000),
|
||||
1f to Color(0xFF000000),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Identity + stats, bottom-left. The avatar sits beside the title (as it
|
||||
// did before the redesign — user request).
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
|
||||
) {
|
||||
headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor)
|
||||
val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
|
||||
if (videoCount > 0) {
|
||||
headerStat(icon = Icons.Filled.VideoLibrary, value = videoCount.toString() + " videos", color = metaColor)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val avatar = channelHeader.avatars.maxByOrNull { it.width }
|
||||
if (avatar != null) {
|
||||
ShimmerAsyncImage(
|
||||
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
base = Color(0xFF333333),
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape),
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = 14.dp)) {
|
||||
Text(
|
||||
text = channelHeader.displayName,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
// "by <owner> on <platform>" instead of the raw handle (user
|
||||
// request): the handle read as noise on a personal instance.
|
||||
val owner = channelHeader.ownerAccount?.let { it.displayName ?: it.name }
|
||||
val platform = AppGraph.tokenStore.instanceName ?: channelHeader.host
|
||||
val byline = when {
|
||||
owner != null && platform != null -> "by $owner on $platform"
|
||||
owner != null -> "by $owner"
|
||||
platform != null -> "on $platform"
|
||||
else -> ""
|
||||
}
|
||||
if (byline.isNotBlank()) {
|
||||
Text(
|
||||
text = byline,
|
||||
color = Color(0xFFBDBDBD),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (channelHeader.createdAt != null) {
|
||||
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
) {
|
||||
headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor)
|
||||
val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
|
||||
if (videoCount > 0) {
|
||||
headerStat(icon = Icons.Filled.VideoLibrary, value = videoCount.toString() + " videos", color = metaColor)
|
||||
}
|
||||
if (channelHeader.createdAt != null) {
|
||||
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
val owner = channelHeader.ownerAccount
|
||||
if (owner != null) {
|
||||
val host = channelHeader.host
|
||||
val platform = AppGraph.tokenStore.instanceName ?: host
|
||||
Text(
|
||||
text = "Owned by " + (owner.displayName ?: owner.name) +
|
||||
(if (platform != null) " - " + platform else ""),
|
||||
color = metaColor,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
|
||||
)
|
||||
}
|
||||
channelHeader.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
Text(
|
||||
text = description,
|
||||
color = Color(0xFFB8B8B8),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 3,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
// Subscribe button, top-right over the banner; LEFT opens the sidebar so
|
||||
// the header is never a dead end (issue #27).
|
||||
SubscribeButton(
|
||||
handle = handle,
|
||||
onLeftEdge = onLeftEdge,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
// Description in the black beneath the banner: a long one grows the header
|
||||
// downward, fading out of the banner that already darkens into black.
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
color = Color(0xFFCCCCCC),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 3,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe/unsubscribe toggle for the channel header (issue #27). Mirrors the
|
||||
* player's action: the server's follow-exists check can lag for federated
|
||||
* channels, so a successful toggle trusts the user's action and re-syncs on the
|
||||
* next screen entry. One request at a time ([busy]).
|
||||
*/
|
||||
@Composable
|
||||
private fun SubscribeButton(
|
||||
handle: String,
|
||||
onLeftEdge: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val repository = AppGraph.repository
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
var subscribed by remember(handle) { mutableStateOf<Boolean?>(null) }
|
||||
var busy by remember(handle) { mutableStateOf(false) }
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(handle) {
|
||||
repository.subscriptionExists(handle).onSuccess { subscribed = it }
|
||||
}
|
||||
val isSub = subscribed == true
|
||||
val bg = when {
|
||||
focused -> Color.White
|
||||
isSub -> Color(0x33CE2B2B)
|
||||
else -> Color(0xFFCE2B2B)
|
||||
}
|
||||
val fg = if (focused) Color(0xFF101010) else Color.White
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp))
|
||||
.background(bg)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.onPreviewKeyEvent { ke ->
|
||||
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
|
||||
ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT
|
||||
) {
|
||||
onLeftEdge(); true
|
||||
} else false
|
||||
}
|
||||
.dpadCenterActivatable {
|
||||
if (busy) return@dpadCenterActivatable
|
||||
busy = true
|
||||
scope.launch {
|
||||
val wasSub = subscribed == true
|
||||
val ok = if (wasSub) repository.unsubscribe(handle).isSuccess
|
||||
else repository.subscribe(handle).isSuccess
|
||||
if (ok) subscribed = !wasSub
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = if (isSub) Icons.Filled.Check else Icons.Filled.Add,
|
||||
contentDescription = null,
|
||||
tint = fg,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = if (subscribed == null) "…" else if (isSub) "Subscribed" else "Subscribe",
|
||||
color = fg,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue