feat(lyrics): implement SyncedLyricsList component and enhance LyricsViewModel for better state management

This commit is contained in:
Kingkor Roy Tirtho 2026-10-16 15:12:10 +06:00
parent 77a9cef3a1
commit 7743d6d4c3
4 changed files with 510 additions and 329 deletions

View File

@ -22,37 +22,45 @@ import org.spotube.millennium.composables.MillenniumBarsLoader
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle 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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.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.PaddingValues
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn 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 dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.core.ui.component.ApplicationBackButton
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
@ -77,15 +84,28 @@ fun LyricsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClose: (() -> Unit)? = null) { onClose: (() -> Unit)? = null) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val positionMillis by viewModel.positionMillisFlow.collectAsState() val colors = MillenniumTheme.colors
val scope = rememberCoroutineScope()
Column(modifier = modifier.fillMaxSize().background(AppTheme.colors.background)) { 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width(48.dp), contentAlignment = Alignment.CenterStart) {
if (onClose != null) { if (onClose != null) {
MillenniumIconButton( MillenniumIconButton(
onClick = onClose, onClick = onClose,
@ -99,225 +119,228 @@ fun LyricsScreen(
} else { } else {
ApplicationBackButton() ApplicationBackButton()
} }
Spacer(Modifier.weight(1f)) }
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))
}
if (isWide) {
Row( 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 modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 40.dp),
horizontalArrangement = Arrangement.spacedBy(40.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
Spacer(modifier = Modifier.height(16.dp)) TrackPane(uiState, Modifier.weight(0.38f))
when { Column(Modifier.weight(0.62f).fillMaxHeight()) {
uiState.isLoading -> { LyricsBody(uiState, viewModel)
Box( }
modifier = Modifier.fillMaxSize(), }
contentAlignment = Alignment.Center } else {
CompactTrackHeader(
uiState,
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp)
)
Column(
Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp)
) { ) {
MillenniumBarsLoader(size = 48.dp) LyricsBody(uiState, viewModel)
}
}
}
} }
} }
uiState.error != null -> { @Composable
Box( private fun ColumnScope.LyricsBody(uiState: LyricsUiState, viewModel: LyricsViewModel) {
modifier = Modifier.fillMaxSize(), when {
contentAlignment = Alignment.Center uiState.isLoading -> CenteredMessage {
) { MillenniumBarsLoader(size = 48.dp)
}
uiState.error != null -> CenteredMessage {
Text( Text(
text = uiState.error ?: stringResource(Res.string.common_unknown_error), text = uiState.error ?: stringResource(Res.string.common_unknown_error),
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.error, color = MillenniumTheme.colors.danger,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
}
uiState.mode == LyricType.SYNCED -> { uiState.mode == LyricType.SYNCED -> SyncedLyricsContent(
SyncedLyricsContent(
syncedLyrics = uiState.syncedLyrics ?: emptyList(), syncedLyrics = uiState.syncedLyrics ?: emptyList(),
currentIndex = viewModel.currentLyricIndex(positionMillis), currentIndex = uiState.currentIndex,
onScrollToIndex = { index -> onLineClick = viewModel::seekToLine
scope.launch {
// scroll will be handled by LazyColumn
}
}
) )
}
uiState.mode == LyricType.STATIC -> { uiState.mode == LyricType.STATIC -> PlainLyricsContent(
PlainLyricsContent(plainLyrics = uiState.plainLyrics ?: "") plainLyrics = uiState.plainLyrics ?: ""
} )
}
}
} }
} }
@Composable @Composable
private fun LyricsModeTab( private fun CoverArt(url: String, size: Dp, radius: Dp, elevation: Dp, modifier: Modifier = Modifier) {
label: String, val colors = MillenniumTheme.colors
isSelected: Boolean,
onClick: () -> Unit) {
Box( Box(
modifier = Modifier modifier = modifier
.clip(RoundedCornerShape(50.dp)) .size(size)
.background( .millenniumSurface(
if (isSelected) { background = millenniumPanelBrush(colors),
AppTheme.colors.primary shape = RoundedCornerShape(radius),
} else { borderColor = colors.border,
Color.Transparent borderWidth = 1.dp,
} shadowElevation = elevation
) )
.clickable(onClick = onClick)
.padding(horizontal = 18.dp, vertical = 7.dp)
) { ) {
Text( AsyncImage(
text = label, model = url.takeIf { it.isNotBlank() },
style = AppTheme.typography.labelLarge, contentDescription = null,
fontWeight = FontWeight.SemiBold, modifier = Modifier.fillMaxSize(),
color = if (isSelected) { contentScale = ContentScale.Crop
AppTheme.colors.onPrimary
} else {
AppTheme.colors.onSurfaceVariant
}
) )
} }
} }
@Composable @Composable
private fun SyncedLyricsContent( 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 = 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<LyricsLine>, syncedLyrics: List<LyricsLine>,
currentIndex: Int, currentIndex: Int,
onScrollToIndex: (Int) -> Unit) { onLineClick: (LyricsLine) -> Unit) {
if (syncedLyrics.isEmpty()) { if (syncedLyrics.isEmpty()) {
Box( CenteredMessage {
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text( Text(
text = stringResource(Res.string.lyrics_no_synced), text = stringResource(Res.string.lyrics_no_synced),
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.onSurfaceVariant, color = MillenniumTheme.colors.muted,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
return return
} }
val listState = rememberLazyListState() SyncedLyricsList(
lines = syncedLyrics,
LaunchedEffect(currentIndex) { currentIndex = currentIndex,
if (currentIndex >= 0) { onLineClick = onLineClick,
listState.animateScrollToItem( modifier = Modifier.weight(1f).fillMaxWidth()
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
}
)
)
}
}
}
}
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 @Composable
private fun PlainLyricsContent( private fun ColumnScope.PlainLyricsContent(
plainLyrics: String) { plainLyrics: String) {
if (plainLyrics.isBlank()) { if (plainLyrics.isBlank()) {
Box( CenteredMessage {
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text( Text(
text = stringResource(Res.string.lyrics_no_plain), text = stringResource(Res.string.lyrics_no_plain),
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.onSurfaceVariant, color = MillenniumTheme.colors.muted,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
return return
} }
LazyColumn { LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 20.dp)
) {
item { item {
Text( Text(
text = plainLyrics, text = plainLyrics,
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() style = AppTheme.typography.titleMedium.copy(lineHeight = 34.sp),
.padding(horizontal = 16.dp), color = MillenniumTheme.colors.text,
style = AppTheme.typography.bodyLarge.copy(lineHeight = 30.sp),
color = AppTheme.colors.onSurface,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }

View File

@ -25,13 +25,16 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginManager
import kotlinx.coroutines.flow.MutableStateFlow 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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.Duration.Companion.milliseconds
@Serializable @Serializable
enum class LyricType { enum class LyricType {
@ -44,22 +47,32 @@ data class LyricsUiState(
val syncedLyrics: List<LyricsLine>? = null, val syncedLyrics: List<LyricsLine>? = null,
val plainLyrics: String? = null, val plainLyrics: String? = null,
val isLoading: Boolean = false, 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( class LyricsViewModel(
private val pluginManager: PluginManager, private val pluginManager: PluginManager,
audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() {
private val _uiState = MutableStateFlow(LyricsUiState()) private val _uiState = MutableStateFlow(LyricsUiState())
val uiState: StateFlow<LyricsUiState> = _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<LyricsUiState> = combine(_uiState, currentIndexFlow) { state, index ->
state.copy(currentIndex = index)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LyricsUiState())
private var currentTrackId: String? = null private var currentTrackId: String? = null
val positionMillisFlow: StateFlow<Long> = audioPlayer.positionFlow
.map { it.inWholeMilliseconds }
.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), 0L)
fun setMode(mode: LyricType) { fun setMode(mode: LyricType) {
_uiState.update { it.copy(mode = mode) } _uiState.update { it.copy(mode = mode) }
} }
@ -106,6 +119,17 @@ class LyricsViewModel(
is QueueEntry.StreamingTrack -> entry.track.id is QueueEntry.StreamingTrack -> entry.track.id
else -> null 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) { if (trackId != null && trackId != currentTrackId) {
loadLyrics() loadLyrics()
} }
@ -113,17 +137,15 @@ class LyricsViewModel(
} }
} }
fun currentLyricIndex(positionMillis: Long): Int { fun seekToLine(line: LyricsLine) {
val lyrics = _uiState.value.syncedLyrics ?: emptyList() viewModelScope.launch {
if (lyrics.isEmpty()) return -1 audioPlayer.seekTo(line.time.milliseconds)
var lastIndex = 0
for (i in lyrics.indices) {
if (lyrics[i].time <= positionMillis) {
lastIndex = i
} else {
break
} }
} }
return lastIndex
private fun activeLineIndex(lyrics: List<LyricsLine>?, positionMillis: Long): Int {
if (lyrics.isNullOrEmpty()) return -1
val index = lyrics.indexOfLast { it.time <= positionMillis }
return index.coerceAtLeast(0)
} }
} }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<LyricsLine>,
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
)
}

View File

@ -39,11 +39,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -62,14 +59,12 @@ import org.spotube.millennium.theme.millenniumPanelBrush
import org.spotube.millennium.theme.millenniumSurface import org.spotube.millennium.theme.millenniumSurface
import com.composeunstyled.SheetDetent import com.composeunstyled.SheetDetent
import com.composeunstyled.rememberModalBottomSheetState import com.composeunstyled.rememberModalBottomSheetState
import androidx.compose.ui.graphics.SolidColor
import com.composeunstyled.Text import com.composeunstyled.Text
import com.composeunstyled.LocalTextStyle import com.composeunstyled.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf 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.DownloadStatus
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel 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.SAVED_TRACKS_COLLECTION_ID
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
@ -659,7 +655,6 @@ fun AppExpandedPlayer(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 16.dp), .padding(bottom = 16.dp),
audioPlayer = audioPlayer,
onExpand = onExpandLyrics onExpand = onExpandLyrics
) )
} }
@ -690,51 +685,20 @@ private fun rememberSharedAlbumArtModifier(
@Composable @Composable
private fun LyricsPreviewCard( private fun LyricsPreviewCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioPlayer: AudioPlayerInterface,
onExpand: () -> Unit, onExpand: () -> Unit,
viewModel: LyricsViewModel = koinViewModel() viewModel: LyricsViewModel = koinViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val syncedLyrics = uiState.syncedLyrics ?: emptyList() val syncedLyrics = uiState.syncedLyrics ?: emptyList()
val isLoading = uiState.isLoading 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 if (syncedLyrics.isEmpty() || isLoading) return
Box( MillenniumCard(
modifier = modifier modifier = modifier.clickable(onClick = onExpand),
.millenniumSurface( contentPadding = PaddingValues(12.dp),
background = SolidColor(AppTheme.colors.surfaceContainerHighest), contentSpacing = 8.dp,
shape = RoundedCornerShape(16.dp),
shadowElevation = 2.dp shadowElevation = 2.dp
)
.clickable(onClick = onExpand)
) { ) {
Column(modifier = Modifier.padding(12.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@ -743,7 +707,7 @@ private fun LyricsPreviewCard(
Text( Text(
text = stringResource(Res.string.player_lyrics), text = stringResource(Res.string.player_lyrics),
style = AppTheme.typography.labelMedium, style = AppTheme.typography.labelMedium,
color = AppTheme.colors.onSurfaceVariant color = MillenniumTheme.colors.muted
) )
MillenniumIconButton( MillenniumIconButton(
onClick = onExpand, onClick = onExpand,
@ -752,45 +716,21 @@ private fun LyricsPreviewCard(
MillenniumIcon( MillenniumIcon(
Iconsax.IconsaxArrowSquareUp, Iconsax.IconsaxArrowSquareUp,
contentDescription = stringResource(Res.string.player_expand_lyrics_content_description), contentDescription = stringResource(Res.string.player_expand_lyrics_content_description),
tint = AppTheme.colors.onSurfaceVariant tint = MillenniumTheme.colors.muted
) )
} }
} }
LazyColumn( // Passive preview: no onLineClick, so lines never seek playback.
state = listState, SyncedLyricsList(
lines = syncedLyrics,
currentIndex = uiState.currentIndex,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(120.dp), .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
) )
} }
} }
}
}
}
@Composable @Composable
private fun OptionTile( private fun OptionTile(