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
|
|
@ -113,10 +113,11 @@ the workflow is strict:
|
||||||
|
|
||||||
## Scope discipline
|
## Scope discipline
|
||||||
|
|
||||||
v0.1 features live in `README.md`; everything else in `BACKLOG.md`. Do not sneak in
|
Shipped features live in `README.md`; anything else is tracked as Forgejo issues at
|
||||||
uploading, editing, subscriptions management, deletion or content creation — the user
|
git.jeena.net/jeena/jtube-tv. Do not sneak in uploading, editing, deletion or content
|
||||||
explicitly excluded them. If the user asks for a scope change, update `BACKLOG.md`
|
creation — the user explicitly excluded them. (Subscribing/unsubscribing to channels is
|
||||||
first, then implement.
|
in scope and already implemented — on the player and the channel page.) If the user asks
|
||||||
|
for a scope change, confirm it with them before implementing.
|
||||||
|
|
||||||
When unsure how a PeerTube endpoint behaves, verify against the live instance instead of
|
When unsure how a PeerTube endpoint behaves, verify against the live instance instead of
|
||||||
guessing (401 ⇒ exists & needs auth, 404 ⇒ route missing, 405 ⇒ wrong method on real route).
|
guessing (401 ⇒ exists & needs auth, 404 ⇒ route missing, 405 ⇒ wrong method on real route).
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package net.jeena.jtubetv.ui.screens.list
|
package net.jeena.jtubetv.ui.screens.list
|
||||||
import androidx.compose.material.icons.Icons
|
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.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Groups
|
import androidx.compose.material.icons.filled.Groups
|
||||||
import androidx.compose.material.icons.filled.VideoLibrary
|
import androidx.compose.material.icons.filled.VideoLibrary
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -159,22 +162,29 @@ fun VideoListScreen(
|
||||||
return
|
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(
|
LazyVerticalGrid(
|
||||||
// Same card size as the Home rows: ~4 fit per row (user request).
|
// Same card size as the Home rows: ~4 fit per row (user request).
|
||||||
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
|
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
|
||||||
state = gridState,
|
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
|
// Search field still scrolls with its results (its own header item);
|
||||||
// they scroll with the content and never stack under the cards.
|
// 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) }) {
|
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
||||||
when (source) {
|
SearchHeader(viewModel, onOpenDrawer)
|
||||||
is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer)
|
|
||||||
is ListSource.Channel -> ChannelHeader(
|
|
||||||
channelHeader = viewModel.channelHeader,
|
|
||||||
videoTotal = viewModel.videoTotal,
|
|
||||||
)
|
|
||||||
else -> {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
|
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
|
||||||
|
|
@ -393,62 +403,113 @@ 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 page header (issue #27): the banner is the backdrop of a fixed-height
|
||||||
* channel description snippet above the videos. */
|
* 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
|
@Composable
|
||||||
private fun ChannelHeader(
|
private fun ChannelHeader(
|
||||||
channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?,
|
channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?,
|
||||||
videoTotal: Long = 0,
|
videoTotal: Long = 0,
|
||||||
|
handle: String,
|
||||||
|
onLeftEdge: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (channelHeader == null) return
|
if (channelHeader == null) return
|
||||||
val metaColor = Color(0xFF9A9A9A)
|
val metaColor = Color(0xFFCFCFCF)
|
||||||
androidx.compose.foundation.layout.Column {
|
|
||||||
val banner = channelHeader.banners.maxByOrNull { it.width }
|
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) {
|
if (banner != null) {
|
||||||
AsyncImage(
|
ShimmerAsyncImage(
|
||||||
model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path),
|
model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier.matchParentSize(),
|
||||||
.fillMaxWidth()
|
|
||||||
.height(120.dp)
|
|
||||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.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),
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
val avatar = channelHeader.avatars.maxByOrNull { it.width }
|
val avatar = channelHeader.avatars.maxByOrNull { it.width }
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
AsyncImage(
|
ShimmerAsyncImage(
|
||||||
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
|
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
|
base = Color(0xFF333333),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = 16.dp, top = 10.dp)
|
.size(56.dp)
|
||||||
.size(52.dp)
|
|
||||||
.clip(androidx.compose.foundation.shape.CircleShape),
|
.clip(androidx.compose.foundation.shape.CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.padding(start = 16.dp)) {
|
Column(modifier = Modifier.padding(start = 14.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = channelHeader.displayName,
|
text = channelHeader.displayName,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = Color.White,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
maxLines = 1,
|
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(
|
||||||
text = channelHeader.name + "@" + channelHeader.host,
|
text = byline,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = Color(0xFFBDBDBD),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Metadata strip between the identity and the videos (issue #14): the
|
}
|
||||||
// counts SmartTube shows, plus creation date and the owning user.
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp),
|
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp),
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp),
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
) {
|
) {
|
||||||
headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor)
|
headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor)
|
||||||
val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
|
val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
|
||||||
|
|
@ -459,32 +520,102 @@ private fun ChannelHeader(
|
||||||
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
|
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val owner = channelHeader.ownerAccount
|
}
|
||||||
if (owner != null) {
|
// Subscribe button, top-right over the banner; LEFT opens the sidebar so
|
||||||
val host = channelHeader.host
|
// the header is never a dead end (issue #27).
|
||||||
val platform = AppGraph.tokenStore.instanceName ?: host
|
SubscribeButton(
|
||||||
Text(
|
handle = handle,
|
||||||
text = "Owned by " + (owner.displayName ?: owner.name) +
|
onLeftEdge = onLeftEdge,
|
||||||
(if (platform != null) " - " + platform else ""),
|
modifier = Modifier
|
||||||
color = metaColor,
|
.align(Alignment.TopEnd)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
.padding(16.dp),
|
||||||
maxLines = 1,
|
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
channelHeader.description?.takeIf { it.isNotBlank() }?.let { description ->
|
// 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(
|
||||||
text = description,
|
text = description,
|
||||||
color = Color(0xFFB8B8B8),
|
color = Color(0xFFCCCCCC),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
maxLines = 3,
|
maxLines = 3,
|
||||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 2.dp),
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun headerStat(
|
private fun headerStat(
|
||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue