diff --git a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt index ced63ff..6ca82bb 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt @@ -4,6 +4,10 @@ import android.view.KeyEvent import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.Column import androidx.compose.foundation.layout.Row @@ -93,15 +97,16 @@ fun PlayerSidebar( .width(SIDEBAR_WIDTH) .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 = when (mode) { - "playlists" -> "Playlists" - "info" -> "Info" - else -> "Comments" - }, + text = if (mode == "playlists") "Playlists" else detail.name, color = Color.White, 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) { "playlists" -> PlaylistsColumn(detail, onDismiss) @@ -161,68 +166,84 @@ private fun InfoColumn( .distinct() .toList() + val annotated = buildAnnotatedString { + var rest = description + while (true) { + val match = UrlPattern.find(rest) ?: break + append(rest.substringBefore(match.value)) + val url = match.value.trimEnd('.', ',', ')') + withLink( + LinkAnnotation.Url( + url, + TextLinkStyles( + SpanStyle( + color = Color(0xFF66B2FF), + textDecoration = TextDecoration.Underline, + ), + ), + { _ -> open(url) }, + ), + ) { append(url) } + rest = rest.substringAfter(match.value) + } + append(rest) + } + + val descFocus = remember { FocusRequester() } val firstLinkFocus = remember { FocusRequester() } - LaunchedEffect(links) { - if (links.isNotEmpty()) { - kotlinx.coroutines.delay(150) - runCatching { firstLinkFocus.requestFocus() } + // 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() } } - LazyColumn(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { if (description.isNotBlank()) { - item(key = "desc") { - Text( - text = buildAnnotatedString { - var rest = description - while (true) { - val match = UrlPattern.find(rest) ?: break - append(rest.substringBefore(match.value)) - val url = match.value.trimEnd('.', ',', ')') - withLink( - LinkAnnotation.Url( - url, - TextLinkStyles( - SpanStyle( - color = Color(0xFF66B2FF), - textDecoration = TextDecoration.Underline, - ), - ), - { _ -> open(url) }, - ), - ) { append(url) } - rest = rest.substringAfter(match.value) - } - append(rest) - }, - color = TextMain, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), - ) - } + DescriptionScroll( + annotated = annotated, + focusRequester = descFocus, + onDismiss = onDismiss, + onLeaveBottom = { + if (links.isNotEmpty()) { + runCatching { firstLinkFocus.requestFocus() }.isSuccess + } else false + }, + modifier = Modifier.weight(1f, fill = false), + ) } if (links.isNotEmpty()) { - item(key = "linkshdr") { - Text( - text = "Links", - color = TextDim, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(start = 20.dp, top = 10.dp, bottom = 2.dp), - ) - } - links.forEachIndexed { index, url -> - item(key = "l" + index + url) { - SidebarRow( - onLeft = onDismiss, - focusRequester = if (index == 0) firstLinkFocus else null, - onClick = { open(url) }, - ) { - Text( - text = linkLabel(url), - color = TextMain, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - ) + 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, + ) + } } } } @@ -230,6 +251,64 @@ 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. */ private fun openLinkFor(ann: LinkAnnotation, block: (LinkAnnotation) -> Unit) = block(ann)