From 7743d6d4c3ff1cc0835fe6462b492da55538d9f5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 15:12:10 +0600 Subject: [PATCH] feat(lyrics): implement SyncedLyricsList component and enhance LyricsViewModel for better state management --- .../spotube/modules/lyrics/LyricsScreen.kt | 457 +++++++++--------- .../spotube/modules/lyrics/LyricsViewModel.kt | 62 ++- .../modules/lyrics/SyncedLyricsList.kt | 196 ++++++++ .../modules/shell/AppExpandedPlayer.kt | 124 ++--- 4 files changed, 510 insertions(+), 329 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/SyncedLyricsList.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt index 22e74fbf..fcec5cef 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt @@ -22,37 +22,45 @@ import org.spotube.millennium.composables.MillenniumBarsLoader import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow +import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImage +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -60,7 +68,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine import dev.krtirtho.spotube.core.ui.component.ApplicationBackButton import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res @@ -77,95 +84,87 @@ fun LyricsScreen( modifier: Modifier = Modifier, onClose: (() -> Unit)? = null) { val uiState by viewModel.uiState.collectAsState() - val positionMillis by viewModel.positionMillisFlow.collectAsState() - val scope = rememberCoroutineScope() + val colors = MillenniumTheme.colors - Column(modifier = modifier.fillMaxSize().background(AppTheme.colors.background)) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically) { - if (onClose != null) { - MillenniumIconButton( - onClick = onClose, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = stringResource(Res.string.common_close) - ) - } - } else { - ApplicationBackButton() - } - Spacer(Modifier.weight(1f)) + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to colors.accent.copy(alpha = 0.14f), + 0.55f to colors.background, + 1f to colors.background + ) + ) + ) { + val isWide = maxWidth >= 840.dp + + Column(Modifier.fillMaxSize()) { Row( modifier = Modifier - .clip(RoundedCornerShape(50.dp)) - .background(AppTheme.colors.surfaceVariant) - .padding(3.dp), - horizontalArrangement = Arrangement.spacedBy(3.dp) - ) { - LyricsModeTab( - label = stringResource(Res.string.lyrics_mode_synced), - isSelected = uiState.mode == LyricType.SYNCED, - onClick = { viewModel.setMode(LyricType.SYNCED) } - ) - LyricsModeTab( - label = stringResource(Res.string.lyrics_mode_plain), - isSelected = uiState.mode == LyricType.STATIC, - onClick = { viewModel.setMode(LyricType.STATIC) } - ) - } - Spacer(Modifier.weight(1f)) - Spacer(Modifier.width(44.dp)) - } - Column( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(16.dp)) - when { - uiState.isLoading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - MillenniumBarsLoader(size = 48.dp) - } - } - - uiState.error != null -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = uiState.error ?: stringResource(Res.string.common_unknown_error), - style = AppTheme.typography.bodyLarge, - color = AppTheme.colors.error, - textAlign = TextAlign.Center - ) - } - } - - uiState.mode == LyricType.SYNCED -> { - SyncedLyricsContent( - syncedLyrics = uiState.syncedLyrics ?: emptyList(), - currentIndex = viewModel.currentLyricIndex(positionMillis), - onScrollToIndex = { index -> - scope.launch { - // scroll will be handled by LazyColumn - } + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.width(48.dp), contentAlignment = Alignment.CenterStart) { + if (onClose != null) { + MillenniumIconButton( + onClick = onClose, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = stringResource(Res.string.common_close) + ) } - ) + } else { + ApplicationBackButton() + } } + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + MillenniumSegmentedButtonRow(Modifier.widthIn(max = 280.dp)) { + MillenniumSegmentedButton( + selected = uiState.mode == LyricType.SYNCED, + onClick = { viewModel.setMode(LyricType.SYNCED) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2), + modifier = Modifier.weight(1f) + ) { Text(stringResource(Res.string.lyrics_mode_synced)) } + MillenniumSegmentedButton( + selected = uiState.mode == LyricType.STATIC, + onClick = { viewModel.setMode(LyricType.STATIC) }, + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2), + modifier = Modifier.weight(1f) + ) { Text(stringResource(Res.string.lyrics_mode_plain)) } + } + } + Spacer(Modifier.width(48.dp)) + } - uiState.mode == LyricType.STATIC -> { - PlainLyricsContent(plainLyrics = uiState.plainLyrics ?: "") + if (isWide) { + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 40.dp), + horizontalArrangement = Arrangement.spacedBy(40.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TrackPane(uiState, Modifier.weight(0.38f)) + Column(Modifier.weight(0.62f).fillMaxHeight()) { + LyricsBody(uiState, viewModel) + } + } + } else { + CompactTrackHeader( + uiState, + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp) + ) + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + LyricsBody(uiState, viewModel) } } } @@ -173,151 +172,175 @@ fun LyricsScreen( } @Composable -private fun LyricsModeTab( - label: String, - isSelected: Boolean, - onClick: () -> Unit) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(50.dp)) - .background( - if (isSelected) { - AppTheme.colors.primary - } else { - Color.Transparent - } +private fun ColumnScope.LyricsBody(uiState: LyricsUiState, viewModel: LyricsViewModel) { + when { + uiState.isLoading -> CenteredMessage { + MillenniumBarsLoader(size = 48.dp) + } + + uiState.error != null -> CenteredMessage { + Text( + text = uiState.error ?: stringResource(Res.string.common_unknown_error), + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.danger, + textAlign = TextAlign.Center ) - .clickable(onClick = onClick) - .padding(horizontal = 18.dp, vertical = 7.dp) - ) { - Text( - text = label, - style = AppTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isSelected) { - AppTheme.colors.onPrimary - } else { - AppTheme.colors.onSurfaceVariant - } + } + + uiState.mode == LyricType.SYNCED -> SyncedLyricsContent( + syncedLyrics = uiState.syncedLyrics ?: emptyList(), + currentIndex = uiState.currentIndex, + onLineClick = viewModel::seekToLine + ) + + uiState.mode == LyricType.STATIC -> PlainLyricsContent( + plainLyrics = uiState.plainLyrics ?: "" ) } } @Composable -private fun SyncedLyricsContent( - syncedLyrics: List, - currentIndex: Int, - onScrollToIndex: (Int) -> Unit) { - if (syncedLyrics.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(Res.string.lyrics_no_synced), - style = AppTheme.typography.bodyLarge, - color = AppTheme.colors.onSurfaceVariant, - textAlign = TextAlign.Center +private fun CoverArt(url: String, size: Dp, radius: Dp, elevation: Dp, modifier: Modifier = Modifier) { + val colors = MillenniumTheme.colors + Box( + modifier = modifier + .size(size) + .millenniumSurface( + background = millenniumPanelBrush(colors), + shape = RoundedCornerShape(radius), + borderColor = colors.border, + borderWidth = 1.dp, + shadowElevation = elevation ) - } - return - } - - val listState = rememberLazyListState() - - LaunchedEffect(currentIndex) { - if (currentIndex >= 0) { - listState.animateScrollToItem( - index = currentIndex.coerceIn(0, syncedLyrics.lastIndex), - scrollOffset = -100 - ) - } - } - - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) ) { - itemsIndexed(syncedLyrics) { index, line -> - val isCurrentLine = index == currentIndex - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .then( - if (isCurrentLine) { - Modifier.background(AppTheme.colors.primaryContainer) - } else { - Modifier - } - ) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = formatLyricTime(line.time), - style = AppTheme.typography.labelSmall, - color = if (isCurrentLine) { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurfaceVariant - }, - modifier = Modifier.width(44.dp) - ) - Text( - text = line.text.ifBlank { "..." }, - modifier = Modifier.weight(1f), - style = AppTheme.typography.bodyLarge.copy( - fontWeight = if (isCurrentLine) FontWeight.Bold else FontWeight.Normal, - color = if (isCurrentLine) { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurface - } - ) - ) - } - } + AsyncImage( + model = url.takeIf { it.isNotBlank() }, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) } } -private fun formatLyricTime(millis: Long): String { - val totalSeconds = (millis / 1000).coerceAtLeast(0) - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - return "$minutes:${seconds.toString().padStart(2, '0')}" -} - @Composable -private fun PlainLyricsContent( - plainLyrics: String) { - if (plainLyrics.isBlank()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { +private fun TrackPane(uiState: LyricsUiState, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CoverArt( + url = uiState.coverUrl, + size = 280.dp, + radius = 24.dp, + elevation = 14.dp + ) + Spacer(Modifier.height(24.dp)) + Text( + text = uiState.trackTitle, + style = AppTheme.typography.headlineSmall, + color = MillenniumTheme.colors.text, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(4.dp)) + Text( + text = uiState.trackArtists, + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun CompactTrackHeader(uiState: LyricsUiState, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CoverArt(url = uiState.coverUrl, size = 48.dp, radius = 12.dp, elevation = 4.dp) + Column(Modifier.weight(1f)) { Text( - text = stringResource(Res.string.lyrics_no_plain), + text = uiState.trackTitle, + style = AppTheme.typography.titleSmall, + color = MillenniumTheme.colors.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = uiState.trackArtists, + style = AppTheme.typography.bodySmall, + color = MillenniumTheme.colors.muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun ColumnScope.CenteredMessage(content: @Composable () -> Unit) { + Box( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center + ) { content() } +} + +@Composable +private fun ColumnScope.SyncedLyricsContent( + syncedLyrics: List, + currentIndex: Int, + onLineClick: (LyricsLine) -> Unit) { + if (syncedLyrics.isEmpty()) { + CenteredMessage { + Text( + text = stringResource(Res.string.lyrics_no_synced), style = AppTheme.typography.bodyLarge, - color = AppTheme.colors.onSurfaceVariant, + color = MillenniumTheme.colors.muted, textAlign = TextAlign.Center ) } return } - LazyColumn { + SyncedLyricsList( + lines = syncedLyrics, + currentIndex = currentIndex, + onLineClick = onLineClick, + modifier = Modifier.weight(1f).fillMaxWidth() + ) +} + +@Composable +private fun ColumnScope.PlainLyricsContent( + plainLyrics: String) { + if (plainLyrics.isBlank()) { + CenteredMessage { + Text( + text = stringResource(Res.string.lyrics_no_plain), + style = AppTheme.typography.bodyLarge, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center + ) + } + return + } + + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 20.dp) + ) { item { Text( text = plainLyrics, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - style = AppTheme.typography.bodyLarge.copy(lineHeight = 30.sp), - color = AppTheme.colors.onSurface, + modifier = Modifier.fillMaxWidth(), + style = AppTheme.typography.titleMedium.copy(lineHeight = 34.sp), + color = MillenniumTheme.colors.text, textAlign = TextAlign.Center ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt index a38c3043..915d2367 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt @@ -25,13 +25,16 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.modules.plugin.PluginManager import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import kotlin.time.Duration.Companion.milliseconds @Serializable enum class LyricType { @@ -44,22 +47,32 @@ data class LyricsUiState( val syncedLyrics: List? = null, val plainLyrics: String? = null, val isLoading: Boolean = false, - val error: String? = null) + val error: String? = null, + val currentIndex: Int = -1, + val trackTitle: String = "", + val trackArtists: String = "", + val coverUrl: String = "") class LyricsViewModel( private val pluginManager: PluginManager, - audioPlayer: AudioPlayerInterface, + private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { private val _uiState = MutableStateFlow(LyricsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + + // Only re-emits when the active line changes, not on every position tick. + private val currentIndexFlow = combine( + _uiState.map { it.syncedLyrics }.distinctUntilChanged(), + audioPlayer.positionFlow + ) { lyrics, position -> activeLineIndex(lyrics, position.inWholeMilliseconds) } + .distinctUntilChanged() + + val uiState: StateFlow = combine(_uiState, currentIndexFlow) { state, index -> + state.copy(currentIndex = index) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LyricsUiState()) private var currentTrackId: String? = null - val positionMillisFlow: StateFlow = audioPlayer.positionFlow - .map { it.inWholeMilliseconds } - .stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), 0L) - fun setMode(mode: LyricType) { _uiState.update { it.copy(mode = mode) } } @@ -106,6 +119,17 @@ class LyricsViewModel( is QueueEntry.StreamingTrack -> entry.track.id else -> null } + if (entry is QueueEntry.StreamingTrack) { + val track = entry.track + _uiState.update { + it.copy( + trackTitle = track.title, + trackArtists = track.artists.joinToString(", ") { artist -> artist.name }, + coverUrl = (track.album?.thumbnails ?: track.thumbnails) + ?.firstOrNull()?.url.orEmpty() + ) + } + } if (trackId != null && trackId != currentTrackId) { loadLyrics() } @@ -113,17 +137,15 @@ class LyricsViewModel( } } - fun currentLyricIndex(positionMillis: Long): Int { - val lyrics = _uiState.value.syncedLyrics ?: emptyList() - if (lyrics.isEmpty()) return -1 - var lastIndex = 0 - for (i in lyrics.indices) { - if (lyrics[i].time <= positionMillis) { - lastIndex = i - } else { - break - } + fun seekToLine(line: LyricsLine) { + viewModelScope.launch { + audioPlayer.seekTo(line.time.milliseconds) } - return lastIndex } -} \ No newline at end of file + + private fun activeLineIndex(lyrics: List?, positionMillis: Long): Int { + if (lyrics.isNullOrEmpty()) return -1 + val index = lyrics.indexOfLast { it.time <= positionMillis } + return index.coerceAtLeast(0) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/SyncedLyricsList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/SyncedLyricsList.kt new file mode 100644 index 00000000..c204d8ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/SyncedLyricsList.kt @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.lyrics + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.theme.MillenniumTheme + +private const val LYRICS_BLANK_LINE = "\u266A" + +/** + * Time-synced lyrics list that keeps the active line vertically centered. + * + * With [onLineClick] it renders Millennium list tiles (the active one is the selected tile); + * without it, it renders a passive compact preview. + */ +@Composable +fun SyncedLyricsList( + lines: List, + currentIndex: Int, + modifier: Modifier = Modifier, + onLineClick: ((LyricsLine) -> Unit)? = null +) { + val listState = rememberLazyListState() + val colors = MillenniumTheme.colors + + BoxWithConstraints(modifier = modifier) { + // Half-viewport padding lets the first/last lines reach the center too. + val edgePadding = maxHeight / 2 + + LaunchedEffect(currentIndex, lines.size) { + if (currentIndex !in lines.indices) return@LaunchedEffect + val visible = listState.layoutInfo.visibleItemsInfo + val itemHeight = visible.firstOrNull { it.index == currentIndex }?.size + ?: visible.firstOrNull()?.size + ?: 0 + listState.animateScrollToItem(index = currentIndex, scrollOffset = itemHeight / 2) + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().then( + if (onLineClick != null) Modifier.fadingEdges(maxHeight * 0.18f) else Modifier + ), + contentPadding = PaddingValues(vertical = edgePadding), + verticalArrangement = Arrangement.spacedBy(if (onLineClick != null) 4.dp else 2.dp) + ) { + itemsIndexed(lines, key = { index, line -> "$index-${line.time}" }) { index, line -> + val isCurrent = index == currentIndex + val text = line.text.ifBlank { LYRICS_BLANK_LINE } + val color by animateColorAsState( + when { + isCurrent -> colors.accent + index < currentIndex -> colors.muted.copy(alpha = 0.55f) + else -> colors.muted + } + ) + if (onLineClick != null) { + ImmersiveLyricLine( + text = text, + distance = kotlin.math.abs(index - currentIndex), + isCurrent = isCurrent, + onClick = { onLineClick(line) } + ) + } else { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + style = AppTheme.typography.bodyMedium.copy( + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, + color = color + ), + textAlign = TextAlign.Center, + maxLines = 2 + ) + } + } + } + } +} + +@Composable +private fun ImmersiveLyricLine( + text: String, + distance: Int, + isCurrent: Boolean, + onClick: () -> Unit +) { + val colors = MillenniumTheme.colors + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val targetAlpha = when (distance) { + 0 -> 1f + 1 -> 0.7f + 2 -> 0.5f + else -> 0.32f + } + val alpha by animateFloatAsState(targetAlpha, tween(300)) + val scale by animateFloatAsState(if (isCurrent) 1f else 0.9f, tween(300)) + val color by animateColorAsState(if (isCurrent) colors.accent else colors.text, tween(300)) + val hoverBackground by animateColorAsState( + if (hovered && !isCurrent) colors.accent.copy(alpha = 0.10f) else Color.Transparent + ) + + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = if (hovered) maxOf(alpha, 0.85f) else alpha + } + .clip(RoundedCornerShape(14.dp)) + .background(hoverBackground) + .hoverable(interaction) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(interactionSource = interaction, indication = null, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + style = AppTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + color = color + ), + textAlign = TextAlign.Center + ) +} + +/** Fades the top and bottom [fade] of the content to transparent. */ +private fun Modifier.fadingEdges(fade: Dp): Modifier = this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val fraction = (fade.toPx() / size.height).coerceIn(0f, 0.5f) + drawRect( + brush = Brush.verticalGradient( + 0f to Color.Transparent, + fraction to Color.Black, + 1f - fraction to Color.Black, + 1f to Color.Transparent + ), + blendMode = BlendMode.DstIn + ) + } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index 22208484..9afbafcd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -39,11 +39,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -62,14 +59,12 @@ import org.spotube.millennium.theme.millenniumPanelBrush import org.spotube.millennium.theme.millenniumSurface import com.composeunstyled.SheetDetent import com.composeunstyled.rememberModalBottomSheetState -import androidx.compose.ui.graphics.SolidColor import com.composeunstyled.Text import com.composeunstyled.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -99,6 +94,7 @@ import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon import dev.krtirtho.spotube.modules.downloads.DownloadStatus import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel +import dev.krtirtho.spotube.modules.lyrics.SyncedLyricsList import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -659,7 +655,6 @@ fun AppExpandedPlayer( modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), - audioPlayer = audioPlayer, onExpand = onExpandLyrics ) } @@ -690,105 +685,50 @@ private fun rememberSharedAlbumArtModifier( @Composable private fun LyricsPreviewCard( modifier: Modifier = Modifier, - audioPlayer: AudioPlayerInterface, onExpand: () -> Unit, viewModel: LyricsViewModel = koinViewModel() ) { val uiState by viewModel.uiState.collectAsState() val syncedLyrics = uiState.syncedLyrics ?: emptyList() val isLoading = uiState.isLoading - - val listState = rememberLazyListState() - val positionMillis by audioPlayer.positionFlow.collectAsState(initial = 0.milliseconds) - - val currentIndex by remember { - derivedStateOf { - if (syncedLyrics.isEmpty()) return@derivedStateOf -1 - var idx = 0 - for (i in syncedLyrics.indices) { - if (syncedLyrics[i].time <= positionMillis.inWholeMilliseconds) { - idx = i - } else { - break - } - } - idx - } - } - - LaunchedEffect(currentIndex) { - if (currentIndex >= 0 && syncedLyrics.isNotEmpty()) { - val centerIndex = currentIndex.coerceIn(0, syncedLyrics.lastIndex) - listState.animateScrollToItem(centerIndex, scrollOffset = -50) - } - } - if (syncedLyrics.isEmpty() || isLoading) return - Box( - modifier = modifier - .millenniumSurface( - background = SolidColor(AppTheme.colors.surfaceContainerHighest), - shape = RoundedCornerShape(16.dp), - shadowElevation = 2.dp - ) - .clickable(onClick = onExpand) + MillenniumCard( + modifier = modifier.clickable(onClick = onExpand), + contentPadding = PaddingValues(12.dp), + contentSpacing = 8.dp, + shadowElevation = 2.dp ) { - Column(modifier = Modifier.padding(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.player_lyrics), + style = AppTheme.typography.labelMedium, + color = MillenniumTheme.colors.muted + ) + MillenniumIconButton( + onClick = onExpand, + style = MillenniumIconButtonStyle.Quiet ) { - Text( - text = stringResource(Res.string.player_lyrics), - style = AppTheme.typography.labelMedium, - color = AppTheme.colors.onSurfaceVariant + MillenniumIcon( + Iconsax.IconsaxArrowSquareUp, + contentDescription = stringResource(Res.string.player_expand_lyrics_content_description), + tint = MillenniumTheme.colors.muted ) - MillenniumIconButton( - onClick = onExpand, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - Iconsax.IconsaxArrowSquareUp, - contentDescription = stringResource(Res.string.player_expand_lyrics_content_description), - tint = AppTheme.colors.onSurfaceVariant - ) - } - } - - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxWidth() - .height(120.dp), - contentPadding = PaddingValues(vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - val displayLines = syncedLyrics.take(6) - itemsIndexed(displayLines) { index, line -> - val isCurrent = - index == currentIndex.coerceIn(0, displayLines.lastIndex.coerceAtLeast(0)) - Text( - text = line.text.ifBlank { "..." }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), - style = AppTheme.typography.bodySmall.copy( - fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, - color = if (isCurrent) { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurfaceVariant.copy(alpha = 0.7f) - } - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center - ) - } } } + + // Passive preview: no onLineClick, so lines never seek playback. + SyncedLyricsList( + lines = syncedLyrics, + currentIndex = uiState.currentIndex, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + ) } }