From 9f9552f870cbcbebde05cdcb865c77ccf5e743fd Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 6 Mar 2026 14:53:34 -0500 Subject: [PATCH] Make lyrics scrollable & clickable --- .../wholphin/ui/detail/music/LyricsContent.kt | 71 +++++++++++++------ .../ui/detail/music/NowPlayingPage.kt | 63 ++++++++++------ 2 files changed, 92 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt index 926e32e1..9a070d40 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt @@ -3,12 +3,9 @@ package com.github.damontecres.wholphin.ui.detail.music import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -24,27 +21,38 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.LyricDto import org.jellyfin.sdk.model.api.LyricLine @Composable fun LyricsContent( + lyricsHaveFocus: Boolean, synced: Boolean, lyrics: LyricDto?, currentLyricPosition: Int?, onClick: (LyricLine) -> Unit, + onFocusLyrics: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { + val focusRequesters = + remember(lyrics) { List(lyrics?.lyrics.orEmpty().size) { FocusRequester() } } val listState = rememberLazyListState(currentLyricPosition ?: 0) val bringIntoViewRequester = remember { BringIntoViewRequester() } if (synced) { LaunchedEffect(currentLyricPosition) { - if (currentLyricPosition != null) { + if (currentLyricPosition != null && !lyricsHaveFocus) { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let { if (currentLyricPosition !in 0..it) { listState.animateScrollToItem(currentLyricPosition) @@ -58,11 +66,30 @@ fun LyricsContent( LazyColumn( state = listState, contentPadding = PaddingValues(), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier + .fillMaxSize() + .focusProperties { + onEnter = { + if (currentLyricPosition != null) { + currentLyricPosition.let { + focusRequesters + .getOrNull(currentLyricPosition) + ?.tryRequestFocus() + } + } else { + focusRequesters.getOrNull(0)?.tryRequestFocus() + } + } + }.onFocusChanged { + onFocusLyrics.invoke(it.hasFocus) + }, ) { if (lyrics?.lyrics?.isNotEmpty() == true) { itemsIndexed(lyrics.lyrics) { index, lyric -> + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() val color by animateColorAsState( if (index == currentLyricPosition || currentLyricPosition == null) { MaterialTheme.colorScheme.onSurface @@ -71,28 +98,32 @@ fun LyricsContent( }, animationSpec = tween(durationMillis = 500, easing = LinearEasing), ) - val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - Box( + Surface( + onClick = { onClick.invoke(lyric) }, + interactionSource = interactionSource, + colors = + ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.border.copy(alpha = .33f), + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)), + scale = + ClickableSurfaceDefaults.scale( + focusedScale = 1f, + pressedScale = .9f, + ), modifier = Modifier - .background( - color = if (focused) MaterialTheme.colorScheme.border.copy(alpha = .66f) else Color.Unspecified, - shape = RoundedCornerShape(8.dp), - ), + .focusRequester(focusRequesters[index]), ) { Text( text = lyric.text, style = MaterialTheme.typography.bodyLarge, - color = color, + color = if (focused) MaterialTheme.colorScheme.onSurface else color, modifier = Modifier - .padding(4.dp) - .clickable( - enabled = !synced, - onClick = { onClick.invoke(lyric) }, - interactionSource = interactionSource, - ).ifElse( + .padding(8.dp) + .ifElse( index == currentLyricPosition, Modifier.bringIntoViewRequester(bringIntoViewRequester), ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index 943d3334..4f95d4bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -55,6 +55,7 @@ import com.github.damontecres.wholphin.ui.playback.BottomDialogItem import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration.Companion.seconds @OptIn(UnstableApi::class) @@ -124,9 +125,20 @@ fun NowPlayingPage( var showViewOptionsDialog by remember { mutableStateOf(false) } var itemMoreDialog by remember { mutableStateOf(null) } + var lyricsHaveFocus by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + + LaunchedEffect(lyricsHaveFocus) { + if (lyricsHaveFocus) { + controllerViewState.hideControls() + } + } + BackHandler(lyricsHaveFocus) { + focusRequester.tryRequestFocus() + } + Box(modifier) { Box( modifier = @@ -193,31 +205,38 @@ fun NowPlayingPage( .align(Alignment.CenterStart), ) } - AnimatedVisibility( - visible = musicPrefs.showLyrics && state.hasLyrics, - enter = expandHorizontally(expandFrom = Alignment.End), - exit = shrinkHorizontally(shrinkTowards = Alignment.End), - modifier = Modifier.align(Alignment.CenterEnd), + } + AnimatedVisibility( + visible = musicPrefs.showLyrics && state.hasLyrics, + enter = expandHorizontally(expandFrom = Alignment.End), + exit = shrinkHorizontally(shrinkTowards = Alignment.End), + modifier = Modifier.align(Alignment.CenterEnd), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxWidth(.5f) + .fillMaxHeight(), ) { - Box( - contentAlignment = Alignment.Center, + LyricsContent( + synced = true, + lyrics = state.lyrics, + currentLyricPosition = state.currentLyricIndex, + lyricsHaveFocus = lyricsHaveFocus, + onFocusLyrics = { lyricsHaveFocus = it }, + onClick = { + it.start + ?.ticks + ?.inWholeMilliseconds + ?.let { player.seekTo(it) } + }, modifier = Modifier - .fillMaxWidth(.5f) - .fillMaxHeight(), - ) { - LyricsContent( - synced = true, - lyrics = state.lyrics, - currentLyricPosition = state.currentLyricIndex, - onClick = {}, - modifier = - Modifier - .padding(vertical = 120.dp) - .fillMaxHeight() - .width(320.dp), - ) - } + .padding(vertical = 120.dp) + .fillMaxHeight() + .width(320.dp), + ) } } val showContextForItem =