Make lyrics scrollable & clickable

This commit is contained in:
Damontecres 2026-03-06 14:53:34 -05:00
parent 1a22980a51
commit 9f9552f870
No known key found for this signature in database
2 changed files with 92 additions and 42 deletions

View file

@ -3,12 +3,9 @@ package com.github.damontecres.wholphin.ui.detail.music
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween 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.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -24,27 +21,38 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.ifElse 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.LyricDto
import org.jellyfin.sdk.model.api.LyricLine import org.jellyfin.sdk.model.api.LyricLine
@Composable @Composable
fun LyricsContent( fun LyricsContent(
lyricsHaveFocus: Boolean,
synced: Boolean, synced: Boolean,
lyrics: LyricDto?, lyrics: LyricDto?,
currentLyricPosition: Int?, currentLyricPosition: Int?,
onClick: (LyricLine) -> Unit, onClick: (LyricLine) -> Unit,
onFocusLyrics: (Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusRequesters =
remember(lyrics) { List(lyrics?.lyrics.orEmpty().size) { FocusRequester() } }
val listState = rememberLazyListState(currentLyricPosition ?: 0) val listState = rememberLazyListState(currentLyricPosition ?: 0)
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
if (synced) { if (synced) {
LaunchedEffect(currentLyricPosition) { LaunchedEffect(currentLyricPosition) {
if (currentLyricPosition != null) { if (currentLyricPosition != null && !lyricsHaveFocus) {
listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let {
if (currentLyricPosition !in 0..it) { if (currentLyricPosition !in 0..it) {
listState.animateScrollToItem(currentLyricPosition) listState.animateScrollToItem(currentLyricPosition)
@ -58,11 +66,30 @@ fun LyricsContent(
LazyColumn( LazyColumn(
state = listState, state = listState,
contentPadding = PaddingValues(), contentPadding = PaddingValues(),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxSize(), 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) { if (lyrics?.lyrics?.isNotEmpty() == true) {
itemsIndexed(lyrics.lyrics) { index, lyric -> itemsIndexed(lyrics.lyrics) { index, lyric ->
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
val color by animateColorAsState( val color by animateColorAsState(
if (index == currentLyricPosition || currentLyricPosition == null) { if (index == currentLyricPosition || currentLyricPosition == null) {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
@ -71,28 +98,32 @@ fun LyricsContent(
}, },
animationSpec = tween(durationMillis = 500, easing = LinearEasing), animationSpec = tween(durationMillis = 500, easing = LinearEasing),
) )
val interactionSource = remember { MutableInteractionSource() } Surface(
val focused by interactionSource.collectIsFocusedAsState() onClick = { onClick.invoke(lyric) },
Box( 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 =
Modifier Modifier
.background( .focusRequester(focusRequesters[index]),
color = if (focused) MaterialTheme.colorScheme.border.copy(alpha = .66f) else Color.Unspecified,
shape = RoundedCornerShape(8.dp),
),
) { ) {
Text( Text(
text = lyric.text, text = lyric.text,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = color, color = if (focused) MaterialTheme.colorScheme.onSurface else color,
modifier = modifier =
Modifier Modifier
.padding(4.dp) .padding(8.dp)
.clickable( .ifElse(
enabled = !synced,
onClick = { onClick.invoke(lyric) },
interactionSource = interactionSource,
).ifElse(
index == currentLyricPosition, index == currentLyricPosition,
Modifier.bringIntoViewRequester(bringIntoViewRequester), Modifier.bringIntoViewRequester(bringIntoViewRequester),
), ),

View file

@ -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.playback.PlaybackKeyHandler
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@ -124,9 +125,20 @@ fun NowPlayingPage(
var showViewOptionsDialog by remember { mutableStateOf(false) } var showViewOptionsDialog by remember { mutableStateOf(false) }
var itemMoreDialog by remember { mutableStateOf<DialogParams?>(null) } var itemMoreDialog by remember { mutableStateOf<DialogParams?>(null) }
var lyricsHaveFocus by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
LaunchedEffect(lyricsHaveFocus) {
if (lyricsHaveFocus) {
controllerViewState.hideControls()
}
}
BackHandler(lyricsHaveFocus) {
focusRequester.tryRequestFocus()
}
Box(modifier) { Box(modifier) {
Box( Box(
modifier = modifier =
@ -193,31 +205,38 @@ fun NowPlayingPage(
.align(Alignment.CenterStart), .align(Alignment.CenterStart),
) )
} }
AnimatedVisibility( }
visible = musicPrefs.showLyrics && state.hasLyrics, AnimatedVisibility(
enter = expandHorizontally(expandFrom = Alignment.End), visible = musicPrefs.showLyrics && state.hasLyrics,
exit = shrinkHorizontally(shrinkTowards = Alignment.End), enter = expandHorizontally(expandFrom = Alignment.End),
modifier = Modifier.align(Alignment.CenterEnd), exit = shrinkHorizontally(shrinkTowards = Alignment.End),
modifier = Modifier.align(Alignment.CenterEnd),
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.fillMaxWidth(.5f)
.fillMaxHeight(),
) { ) {
Box( LyricsContent(
contentAlignment = Alignment.Center, synced = true,
lyrics = state.lyrics,
currentLyricPosition = state.currentLyricIndex,
lyricsHaveFocus = lyricsHaveFocus,
onFocusLyrics = { lyricsHaveFocus = it },
onClick = {
it.start
?.ticks
?.inWholeMilliseconds
?.let { player.seekTo(it) }
},
modifier = modifier =
Modifier Modifier
.fillMaxWidth(.5f) .padding(vertical = 120.dp)
.fillMaxHeight(), .fillMaxHeight()
) { .width(320.dp),
LyricsContent( )
synced = true,
lyrics = state.lyrics,
currentLyricPosition = state.currentLyricIndex,
onClick = {},
modifier =
Modifier
.padding(vertical = 120.dp)
.fillMaxHeight()
.width(320.dp),
)
}
} }
} }
val showContextForItem = val showContextForItem =