player: title header + scrollable description in info/comments (#23, #24)

The info and comments side panels showed a generic "Info"/"Comments" label,
which read as an empty black bar with no clue which video it belonged to. Both
now show the video's title instead (#24, and the same complaint in #23).

The info panel's description also could not be scrolled with the D-pad: a plain
Text in a LazyColumn takes no focus, so a long description was unreadable past
the first screenful unless it happened to contain links. It is now a focusable,
D-pad-scrollable box; Down at the bottom hands focus to the link list, Left
closes the panel (#23).
This commit is contained in:
Jeena 2026-10-15 12:17:54 +00:00
parent 9bdca8c985
commit 6307addfb8

View file

@ -4,6 +4,10 @@ import android.view.KeyEvent
import androidx.compose.foundation.background import androidx.compose.foundation.background
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.animateScrollBy
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.heightIn
import androidx.compose.ui.layout.onSizeChanged
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.Row
@ -93,15 +97,16 @@ fun PlayerSidebar(
.width(SIDEBAR_WIDTH) .width(SIDEBAR_WIDTH)
.background(SidebarBg), .background(SidebarBg),
) { ) {
// Header = the video's title (issue #23/#24): the old generic "Info" /
// "Comments" label read as an empty black bar with no context about
// which video the panel belongs to. Playlists keeps its own label.
Text( Text(
text = when (mode) { text = if (mode == "playlists") "Playlists" else detail.name,
"playlists" -> "Playlists"
"info" -> "Info"
else -> "Comments"
},
color = Color.White, color = Color.White,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(20.dp), maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 10.dp),
) )
when (mode) { when (mode) {
"playlists" -> PlaylistsColumn(detail, onDismiss) "playlists" -> PlaylistsColumn(detail, onDismiss)
@ -161,19 +166,7 @@ private fun InfoColumn(
.distinct() .distinct()
.toList() .toList()
val firstLinkFocus = remember { FocusRequester() } val annotated = buildAnnotatedString {
LaunchedEffect(links) {
if (links.isNotEmpty()) {
kotlinx.coroutines.delay(150)
runCatching { firstLinkFocus.requestFocus() }
}
}
LazyColumn(Modifier.fillMaxSize()) {
if (description.isNotBlank()) {
item(key = "desc") {
Text(
text = buildAnnotatedString {
var rest = description var rest = description
while (true) { while (true) {
val match = UrlPattern.find(rest) ?: break val match = UrlPattern.find(rest) ?: break
@ -194,26 +187,53 @@ private fun InfoColumn(
rest = rest.substringAfter(match.value) rest = rest.substringAfter(match.value)
} }
append(rest) append(rest)
}
val descFocus = remember { FocusRequester() }
val firstLinkFocus = remember { FocusRequester() }
// Focus the description so the D-pad can scroll it right away (issue #23):
// a description with no links used to have nothing focusable, so a long
// text could never be scrolled down with the remote. Fall back to the
// first link when there is no description text.
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(150)
runCatching {
if (description.isNotBlank()) descFocus.requestFocus()
else firstLinkFocus.requestFocus()
}
}
Column(Modifier.fillMaxSize()) {
if (description.isNotBlank()) {
DescriptionScroll(
annotated = annotated,
focusRequester = descFocus,
onDismiss = onDismiss,
onLeaveBottom = {
if (links.isNotEmpty()) {
runCatching { firstLinkFocus.requestFocus() }.isSuccess
} else false
}, },
color = TextMain, modifier = Modifier.weight(1f, fill = false),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
) )
} }
}
if (links.isNotEmpty()) { if (links.isNotEmpty()) {
item(key = "linkshdr") {
Text( Text(
text = "Links", text = "Links",
color = TextDim, color = TextDim,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 20.dp, top = 10.dp, bottom = 2.dp), modifier = Modifier.padding(start = 20.dp, top = 10.dp, bottom = 2.dp),
) )
} LazyColumn(Modifier.heightIn(max = 220.dp)) {
links.forEachIndexed { index, url -> links.forEachIndexed { index, url ->
item(key = "l" + index + url) { item(key = "l" + index + url) {
SidebarRow( SidebarRow(
onLeft = onDismiss, onLeft = onDismiss,
onUp = if (index == 0 && description.isNotBlank()) {
{ runCatching { descFocus.requestFocus() } }
} else null,
atTop = index == 0 && description.isBlank(),
atBottom = index == links.size - 1,
focusRequester = if (index == 0) firstLinkFocus else null, focusRequester = if (index == 0) firstLinkFocus else null,
onClick = { open(url) }, onClick = { open(url) },
) { ) {
@ -228,6 +248,65 @@ private fun InfoColumn(
} }
} }
} }
}
}
/**
* Focusable, D-pad-scrollable description (issue #23). On TV a plain Text in a
* LazyColumn can't be scrolled with the remote — nothing in it takes focus — so
* long descriptions were unreadable past the first screenful. This box takes
* focus and scrolls on UP/DOWN; LEFT closes the panel; DOWN at the bottom hands
* focus to the link list (if any).
*/
@Composable
private fun DescriptionScroll(
annotated: androidx.compose.ui.text.AnnotatedString,
focusRequester: FocusRequester,
onDismiss: () -> Unit,
onLeaveBottom: () -> Boolean,
modifier: Modifier = Modifier,
) {
val scroll = androidx.compose.foundation.rememberScrollState()
val scope = rememberCoroutineScope()
var focused by remember { mutableStateOf(false) }
var viewportPx by remember { mutableStateOf(0) }
Box(
modifier
.fillMaxWidth()
.onFocusChanged { focused = it.isFocused }
.focusRequester(focusRequester)
.focusable()
.background(if (focused) Color(0x0DFFFFFF) else Color.Transparent)
.onPreviewKeyEvent { ke ->
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
val step = (viewportPx * 0.85f).coerceAtLeast(240f)
when (ke.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_DOWN ->
if (scroll.value < scroll.maxValue) {
scope.launch { scroll.animateScrollBy(step) }
true
} else onLeaveBottom()
KeyEvent.KEYCODE_DPAD_UP ->
if (scroll.value > 0) {
scope.launch { scroll.animateScrollBy(-step) }
true
} else true // nothing above the description; stay put
KeyEvent.KEYCODE_DPAD_LEFT -> {
onDismiss(); true
}
else -> false
}
}
.verticalScroll(scroll)
.onSizeChanged { viewportPx = it.height },
) {
Text(
text = annotated,
color = TextMain,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
)
}
} }
/** Link click listener: the annotation callback shape. */ /** Link click listener: the annotation callback shape. */