player: sidebar starts at the top, links stay in the text (#23, #24)

The info and comments panels sat under a hard-coded 48dp top padding, leaving a
black strip above them; they now fill the height from the very top (#24, and the
same note on #23).

The info panel no longer repeats the description's links in a list underneath —
they stay inline in the running text and OK opens the first one. UP/DOWN are
always consumed while scrolling, so the panel can no longer strand focus in a
place it can't scroll back from (#23 review).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jeena 2026-10-15 14:20:52 +00:00
parent 904020da3c
commit f8a36a73e5
2 changed files with 28 additions and 60 deletions

View file

@ -190,64 +190,28 @@ private fun InfoColumn(
} }
val descFocus = remember { FocusRequester() } val descFocus = remember { FocusRequester() }
val firstLinkFocus = remember { FocusRequester() } // The description owns the focus for the whole panel: UP/DOWN scroll it and are
// Focus the description so the D-pad can scroll it right away (issue #23): // always consumed, so scrolling can never strand focus somewhere it can't come
// a description with no links used to have nothing focusable, so a long // back from (issue #23 review: "it loses focus and it's impossible to scroll up").
// text could never be scrolled down with the remote. Fall back to the
// first link when there is no description text.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
kotlinx.coroutines.delay(150) kotlinx.coroutines.delay(150)
runCatching { runCatching { descFocus.requestFocus() }
if (description.isNotBlank()) descFocus.requestFocus()
else firstLinkFocus.requestFocus()
}
} }
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
if (description.isNotBlank()) { if (description.isBlank()) {
SidebarFallback("No description.", onDismiss)
} else {
DescriptionScroll( DescriptionScroll(
annotated = annotated, annotated = annotated,
focusRequester = descFocus, focusRequester = descFocus,
onDismiss = onDismiss, onDismiss = onDismiss,
onLeaveBottom = { // Links stay inline in the running text (issue #23 review) instead of
if (links.isNotEmpty()) { // being repeated in a list underneath; OK opens the first one.
runCatching { firstLinkFocus.requestFocus() }.isSuccess onActivate = { links.firstOrNull()?.let { open(it) } },
} else false
},
modifier = Modifier.weight(1f, fill = false), modifier = Modifier.weight(1f, fill = false),
) )
} }
if (links.isNotEmpty()) {
Text(
text = "Links",
color = TextDim,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 20.dp, top = 10.dp, bottom = 2.dp),
)
LazyColumn(Modifier.heightIn(max = 220.dp)) {
links.forEachIndexed { index, url ->
item(key = "l" + index + url) {
SidebarRow(
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,
onClick = { open(url) },
) {
Text(
text = linkLabel(url),
color = TextMain,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
)
}
}
}
}
}
} }
} }
@ -263,7 +227,7 @@ private fun DescriptionScroll(
annotated: androidx.compose.ui.text.AnnotatedString, annotated: androidx.compose.ui.text.AnnotatedString,
focusRequester: FocusRequester, focusRequester: FocusRequester,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onLeaveBottom: () -> Boolean, onActivate: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scroll = androidx.compose.foundation.rememberScrollState() val scroll = androidx.compose.foundation.rememberScrollState()
@ -281,19 +245,23 @@ private fun DescriptionScroll(
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
val step = (viewportPx * 0.85f).coerceAtLeast(240f) val step = (viewportPx * 0.85f).coerceAtLeast(240f)
when (ke.nativeKeyEvent.keyCode) { when (ke.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_DOWN -> // UP/DOWN are always consumed: they scroll while there is room and
if (scroll.value < scroll.maxValue) { // otherwise do nothing, so focus stays here and the text can always
scope.launch { scroll.animateScrollBy(step) } // be scrolled back up (issue #23 review).
true KeyEvent.KEYCODE_DPAD_DOWN -> {
} else onLeaveBottom() if (scroll.value < scroll.maxValue) scope.launch { scroll.animateScrollBy(step) }
KeyEvent.KEYCODE_DPAD_UP -> true
if (scroll.value > 0) { }
scope.launch { scroll.animateScrollBy(-step) } KeyEvent.KEYCODE_DPAD_UP -> {
true if (scroll.value > 0) scope.launch { scroll.animateScrollBy(-step) }
} else true // nothing above the description; stay put true
}
KeyEvent.KEYCODE_DPAD_LEFT -> { KeyEvent.KEYCODE_DPAD_LEFT -> {
onDismiss(); true onDismiss(); true
} }
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
onActivate(); true
}
else -> false else -> false
} }
} }

View file

@ -646,9 +646,9 @@ private fun PlayerSurface(
closeSidebar() closeSidebar()
}, },
onOpenVideo = onOpenVideo, onOpenVideo = onOpenVideo,
modifier = Modifier // Full height from the very top — the old top padding left a black
.align(Alignment.CenterEnd) // strip above the panel (issues #23/#24).
.padding(top = 48.dp), modifier = Modifier.align(Alignment.TopEnd),
) )
} }
} }