feat: fix voting functionality for SimpMusic lyrics

This commit is contained in:
maxrave-dev 2026-01-18 14:34:27 +07:00
parent ab1cf3eb95
commit 928d9e80dd
7 changed files with 195 additions and 110 deletions

View file

@ -10,6 +10,8 @@ max_line_length = 150
tab_width = 4
ktlint_function_naming_ignore_when_annotated_with = Composable
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_no-trailing-spaces = disabled
ktlint_standard_blank-line-between-when-conditions = disabled
[*.md]
generated_code = true

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.maxrave.simpmusic.ui.theme.typo
import com.maxrave.simpmusic.viewModel.VoteData
import com.maxrave.simpmusic.viewModel.VoteState
import org.jetbrains.compose.resources.stringResource
import simpmusic.composeapp.generated.resources.Res
@ -36,8 +37,8 @@ import simpmusic.composeapp.generated.resources.vote_for_lyrics
fun VoteLyricsDialog(
canVoteLyrics: Boolean,
canVoteTranslatedLyrics: Boolean,
lyricsVoteState: VoteState,
translatedLyricsVoteState: VoteState,
lyricsVoteState: VoteData?,
translatedLyricsVoteState: VoteData?,
onVoteLyrics: (upvote: Boolean) -> Unit,
onVoteTranslatedLyrics: (upvote: Boolean) -> Unit,
onDismiss: () -> Unit,
@ -64,7 +65,7 @@ fun VoteLyricsDialog(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Vote for original lyrics
if (canVoteLyrics) {
if (canVoteLyrics && lyricsVoteState != null) {
VoteRow(
label = stringResource(Res.string.rate_lyrics),
voteState = lyricsVoteState,
@ -74,7 +75,7 @@ fun VoteLyricsDialog(
}
// Vote for translated lyrics
if (canVoteTranslatedLyrics) {
if (canVoteTranslatedLyrics && translatedLyricsVoteState != null) {
VoteRow(
label = stringResource(Res.string.rate_translated_lyrics),
voteState = translatedLyricsVoteState,
@ -90,67 +91,84 @@ fun VoteLyricsDialog(
@Composable
private fun VoteRow(
label: String,
voteState: VoteState,
voteState: VoteData,
onUpvote: () -> Unit,
onDownvote: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = typo().bodySmall,
)
when (voteState) {
is VoteState.Loading -> {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
is VoteState.Success -> {
Icon(
imageVector = if (voteState.upvote) Icons.Rounded.ThumbUpAlt else Icons.Rounded.ThumbDownAlt,
contentDescription = null,
tint = Color.Cyan,
modifier = Modifier.size(24.dp),
)
}
is VoteState.Error -> {
Text(
text = voteState.message,
style = typo().bodySmall,
color = Color.Red,
)
}
is VoteState.Idle -> {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
IconButton(
onClick = onUpvote,
modifier = Modifier.size(36.dp),
Column {
Text(
text = label,
style = typo().bodySmall,
)
Text(
text = "ID: ${voteState.id}",
style = typo().bodySmall,
)
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Vote: ${voteState.vote}",
style = typo().bodySmall,
)
when (voteState.state) {
is VoteState.Loading -> {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
is VoteState.Success -> {
Icon(
imageVector = if (voteState.state.upvote) Icons.Rounded.ThumbUpAlt else Icons.Rounded.ThumbDownAlt,
contentDescription = null,
tint = Color.Cyan,
modifier = Modifier.size(24.dp),
)
}
is VoteState.Error -> {
Text(
text = voteState.state.message,
style = typo().bodySmall,
color = Color.Red,
)
}
is VoteState.Idle -> {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = Icons.Rounded.ThumbUp,
contentDescription = stringResource(Res.string.upvote),
modifier = Modifier.size(20.dp),
)
}
IconButton(
onClick = onDownvote,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.ThumbDown,
contentDescription = stringResource(Res.string.downvote),
modifier = Modifier.size(20.dp),
)
IconButton(
onClick = onUpvote,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.ThumbUp,
contentDescription = stringResource(Res.string.upvote),
modifier = Modifier.size(20.dp),
)
}
IconButton(
onClick = onDownvote,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.ThumbDown,
contentDescription = stringResource(Res.string.downvote),
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
}
}
}

View file

@ -96,13 +96,13 @@ import com.maxrave.domain.manager.DataStoreManager
import com.maxrave.domain.utils.connectArtists
import com.maxrave.logger.Logger
import com.maxrave.simpmusic.Platform
import com.maxrave.simpmusic.expect.toggleMiniPlayer
import com.maxrave.simpmusic.expect.ui.PlatformBackdrop
import com.maxrave.simpmusic.expect.ui.drawBackdropCustomShape
import com.maxrave.simpmusic.expect.ui.toImageBitmap
import com.maxrave.simpmusic.extension.formatDuration
import com.maxrave.simpmusic.extension.getColorFromPalette
import com.maxrave.simpmusic.extension.toResizedBitmap
import com.maxrave.simpmusic.expect.toggleMiniPlayer
import com.maxrave.simpmusic.getPlatform
import com.maxrave.simpmusic.ui.component.ExplicitBadge
import com.maxrave.simpmusic.ui.component.HeartCheckBox
@ -161,7 +161,7 @@ fun MiniPlayer(
thumbnail.readPixels(buffer)
}
} catch (e: Exception) {
Logger.e(TAG, "Error getting pixels from layer: ${e.localizedMessage}")
Logger.e(TAG, "Error getting pixels from layer: ${e.message}")
}
val averageLuminance =
(0 until 25).sumOf { index ->
@ -816,7 +816,7 @@ fun MiniPlayer(
IconButton(onClick = { toggleMiniPlayer() }) {
Icon(
imageVector = Icons.Outlined.OpenInNew,
contentDescription = "Mini Player"
contentDescription = "Mini Player",
)
}
}
@ -825,14 +825,16 @@ fun MiniPlayer(
// Toggle mute/unmute
val newVolume = if (controllerState.volume > 0f) 0f else 1f
sharedViewModel.onUIEvent(UIEvent.UpdateVolume(newVolume))
}
},
) {
Icon(
imageVector = if (controllerState.volume > 0f)
Icons.Filled.VolumeUp
else
Icons.Filled.VolumeOff,
contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute"
imageVector =
if (controllerState.volume > 0f) {
Icons.Filled.VolumeUp
} else {
Icons.Filled.VolumeOff
},
contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute",
)
}
Spacer(Modifier.width(4.dp))

View file

@ -181,6 +181,7 @@ import simpmusic.composeapp.generated.resources.artists
import simpmusic.composeapp.generated.resources.baseline_fullscreen_24
import simpmusic.composeapp.generated.resources.baseline_more_vert_24
import simpmusic.composeapp.generated.resources.description
import simpmusic.composeapp.generated.resources.downvote
import simpmusic.composeapp.generated.resources.holder
import simpmusic.composeapp.generated.resources.holder_video
import simpmusic.composeapp.generated.resources.like_and_dislike
@ -199,7 +200,6 @@ import simpmusic.composeapp.generated.resources.show
import simpmusic.composeapp.generated.resources.spotify_lyrics_provider
import simpmusic.composeapp.generated.resources.unsynced
import simpmusic.composeapp.generated.resources.upvote
import simpmusic.composeapp.generated.resources.downvote
import simpmusic.composeapp.generated.resources.view_count
import simpmusic.composeapp.generated.resources.vote_error
import simpmusic.composeapp.generated.resources.vote_submitted
@ -535,10 +535,17 @@ fun NowPlayingScreenContent(
// Vote Dialog
if (showVoteDialog) {
val canVoteLyrics = screenDataState.lyricsData?.lyricsProvider == LyricsProvider.SIMPMUSIC &&
!screenDataState.lyricsData?.lyrics?.simpMusicLyricsId.isNullOrEmpty()
val canVoteTranslatedLyrics = screenDataState.lyricsData?.translatedLyrics?.second == LyricsProvider.SIMPMUSIC &&
!screenDataState.lyricsData?.translatedLyrics?.first?.simpMusicLyricsId.isNullOrEmpty()
val canVoteLyrics =
screenDataState.lyricsData?.lyricsProvider == LyricsProvider.SIMPMUSIC &&
screenDataState.lyricsData
?.lyrics
?.simpMusicLyrics != null
val canVoteTranslatedLyrics =
screenDataState.lyricsData?.translatedLyrics?.second == LyricsProvider.SIMPMUSIC &&
screenDataState.lyricsData
?.translatedLyrics
?.first
?.simpMusicLyrics != null
VoteLyricsDialog(
canVoteLyrics = canVoteLyrics,
@ -1536,10 +1543,17 @@ fun NowPlayingScreenContent(
}
Spacer(modifier = Modifier.weight(1f))
// Vote button - only show if lyrics or translated lyrics from SimpMusic
val canVoteLyrics = screenDataState.lyricsData?.lyricsProvider == LyricsProvider.SIMPMUSIC &&
!screenDataState.lyricsData?.lyrics?.simpMusicLyricsId.isNullOrEmpty()
val canVoteTranslatedLyrics = screenDataState.lyricsData?.translatedLyrics?.second == LyricsProvider.SIMPMUSIC &&
!screenDataState.lyricsData?.translatedLyrics?.first?.simpMusicLyricsId.isNullOrEmpty()
val canVoteLyrics =
screenDataState.lyricsData?.lyricsProvider == LyricsProvider.SIMPMUSIC &&
screenDataState.lyricsData
?.lyrics
?.simpMusicLyrics != null
val canVoteTranslatedLyrics =
screenDataState.lyricsData?.translatedLyrics?.second == LyricsProvider.SIMPMUSIC &&
screenDataState.lyricsData
?.translatedLyrics
?.first
?.simpMusicLyrics != null
if (canVoteLyrics || canVoteTranslatedLyrics) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
IconButton(

View file

@ -98,6 +98,7 @@ import simpmusic.composeapp.generated.resources.shared
import simpmusic.composeapp.generated.resources.updated
import simpmusic.composeapp.generated.resources.vote_submitted
import java.io.FileOutputStream
import java.lang.Exception
import kotlin.math.abs
import kotlin.reflect.KClass
@ -301,7 +302,6 @@ class SharedViewModel(
it.songEntity?.videoId
}.collectLatest { state ->
Logger.w(tag, "NowPlayingState is $state")
resetLyricsVoteState()
canvasJob?.cancel()
_nowPlayingState.value = state
state.songEntity?.let { track ->
@ -1007,7 +1007,7 @@ class SharedViewModel(
dataStoreManager.translationLanguage.first(),
)
log("Removed out-of-sync translated lyrics for $videoId")
val simpMusicLyricsId = lyrics.simpMusicLyricsId
val simpMusicLyricsId = lyrics.simpMusicLyrics?.id
if (lyricsProvider == LyricsProvider.SIMPMUSIC && !simpMusicLyricsId.isNullOrEmpty()) {
viewModelScope.launch {
lyricsCanvasRepository
@ -1052,6 +1052,14 @@ class SharedViewModel(
val track = _nowPlayingState.value?.track
when (isTranslatedLyrics) {
true -> {
if (lyricsProvider == LyricsProvider.SIMPMUSIC) {
_translatedVoteState.value =
VoteData(
id = lyrics.simpMusicLyrics?.id ?: "",
vote = lyrics.simpMusicLyrics?.vote ?: 0,
state = VoteState.Idle,
)
}
_nowPlayingScreenData.update {
it.copy(
lyricsData =
@ -1084,6 +1092,14 @@ class SharedViewModel(
}
false -> {
if (lyricsProvider == LyricsProvider.SIMPMUSIC) {
_lyricsVoteState.value =
VoteData(
id = lyrics.simpMusicLyrics?.id ?: "",
vote = lyrics.simpMusicLyrics?.vote ?: 0,
state = VoteState.Idle,
)
}
_nowPlayingScreenData.update {
it.copy(
lyricsData =
@ -1153,6 +1169,7 @@ class SharedViewModel(
?.artist
?: ""
}
resetLyricsVoteState()
val lyricsProvider = dataStoreManager.lyricsProvider.first()
if (isVideo) {
getYouTubeCaption(
@ -1461,7 +1478,7 @@ class SharedViewModel(
}
}
fun setLyricsProvider() {
private fun setLyricsProvider() {
viewModelScope.launch {
val songEntity = nowPlayingState.value?.songEntity ?: return@launch
val isVideo = nowPlayingState.value?.mediaItem?.isVideo() ?: false
@ -1561,7 +1578,7 @@ class SharedViewModel(
fileOutputStream.write(bytesArray)
fileOutputStream.close()
Logger.d(tag, "Thumbnail saved to $path.jpg")
} catch (e: java.lang.Exception) {
} catch (e: Exception) {
throw RuntimeException(e)
}
songRepository
@ -1607,12 +1624,12 @@ class SharedViewModel(
}
// Vote state for translated lyrics
private val _translatedVoteState = MutableStateFlow<VoteState>(VoteState.Idle)
val translatedVoteState: StateFlow<VoteState> = _translatedVoteState.asStateFlow()
private val _translatedVoteState = MutableStateFlow<VoteData?>(null)
val translatedVoteState: StateFlow<VoteData?> = _translatedVoteState.asStateFlow()
// Vote state for original lyrics
private val _lyricsVoteState = MutableStateFlow<VoteState>(VoteState.Idle)
val lyricsVoteState: StateFlow<VoteState> = _lyricsVoteState.asStateFlow()
private val _lyricsVoteState = MutableStateFlow<VoteData?>(null)
val lyricsVoteState: StateFlow<VoteData?> = _lyricsVoteState.asStateFlow()
/**
* Vote for SimpMusic original lyrics (upvote or downvote)
@ -1621,15 +1638,19 @@ class SharedViewModel(
fun voteLyrics(upvote: Boolean) {
val lyricsData = _nowPlayingScreenData.value.lyricsData
val lyricsProvider = lyricsData?.lyricsProvider
val simpMusicLyricsId = lyricsData?.lyrics?.simpMusicLyricsId
val simpMusicLyricsId = lyricsData?.lyrics?.simpMusicLyrics?.id ?: return
if (lyricsProvider != LyricsProvider.SIMPMUSIC || simpMusicLyricsId.isNullOrEmpty()) {
if (lyricsProvider != LyricsProvider.SIMPMUSIC || simpMusicLyricsId.isEmpty()) {
Logger.w(tag, "Cannot vote: not a SimpMusic lyrics or missing ID")
return
}
viewModelScope.launch {
_lyricsVoteState.value = VoteState.Loading
_lyricsVoteState.update {
it?.copy(
state = VoteState.Loading,
)
}
lyricsCanvasRepository
.voteSimpMusicLyrics(
lyricsId = simpMusicLyricsId,
@ -1638,12 +1659,20 @@ class SharedViewModel(
when (result) {
is Resource.Error -> {
Logger.w(tag, "Vote SimpMusic Lyrics Error ${result.message}")
_lyricsVoteState.value = VoteState.Error(result.message ?: "Unknown error")
_lyricsVoteState.update {
it?.copy(
state = VoteState.Error(result.message ?: "Unknown error"),
)
}
}
is Resource.Success -> {
Logger.d(tag, "Vote SimpMusic Lyrics Success")
_lyricsVoteState.value = VoteState.Success(upvote)
_lyricsVoteState.update {
it?.copy(
state = VoteState.Success(upvote),
)
}
makeToast(getString(Res.string.vote_submitted))
}
}
@ -1651,9 +1680,9 @@ class SharedViewModel(
}
}
fun resetLyricsVoteState() {
_lyricsVoteState.value = VoteState.Idle
_translatedVoteState.value = VoteState.Idle
private fun resetLyricsVoteState() {
_lyricsVoteState.value = null
_translatedVoteState.value = null
}
/**
@ -1663,15 +1692,19 @@ class SharedViewModel(
fun voteTranslatedLyrics(upvote: Boolean) {
val translatedLyrics = _nowPlayingScreenData.value.lyricsData?.translatedLyrics
val lyricsProvider = translatedLyrics?.second
val simpMusicLyricsId = translatedLyrics?.first?.simpMusicLyricsId
val simpMusicLyricsId = translatedLyrics?.first?.simpMusicLyrics?.id ?: return
if (lyricsProvider != LyricsProvider.SIMPMUSIC || simpMusicLyricsId.isNullOrEmpty()) {
if (lyricsProvider != LyricsProvider.SIMPMUSIC || simpMusicLyricsId.isEmpty()) {
Logger.w(tag, "Cannot vote: not a SimpMusic translated lyrics or missing ID")
return
}
viewModelScope.launch {
_translatedVoteState.value = VoteState.Loading
_translatedVoteState.update {
it?.copy(
state = VoteState.Loading,
)
}
lyricsCanvasRepository
.voteSimpMusicTranslatedLyrics(
translatedLyricsId = simpMusicLyricsId,
@ -1680,12 +1713,16 @@ class SharedViewModel(
when (result) {
is Resource.Error -> {
Logger.w(tag, "Vote SimpMusic Translated Lyrics Error ${result.message}")
_translatedVoteState.value = VoteState.Error(result.message ?: "Unknown error")
_translatedVoteState.update {
it?.copy(
state = VoteState.Error(result.message ?: "Unknown error"),
)
}
}
is Resource.Success -> {
Logger.d(tag, "Vote SimpMusic Translated Lyrics Success")
_translatedVoteState.value = VoteState.Success(upvote)
_translatedVoteState.update { it?.copy(state = VoteState.Success(upvote)) }
makeToast(getString(Res.string.vote_submitted))
}
}
@ -1693,10 +1730,6 @@ class SharedViewModel(
}
}
fun resetVoteState() {
_translatedVoteState.value = VoteState.Idle
}
fun shouldStopMusicService(): Boolean = runBlocking { dataStoreManager.killServiceOnExit.first() == TRUE }
fun isUserLoggedIn(): Boolean = runBlocking { dataStoreManager.cookie.first().isNotEmpty() }
@ -1779,9 +1812,22 @@ data class NowPlayingScreenData(
}
}
data class VoteData(
val id: String,
val vote: Int,
val state: VoteState,
)
sealed class VoteState {
data object Idle : VoteState()
data object Loading : VoteState()
data class Success(val upvote: Boolean) : VoteState()
data class Error(val message: String) : VoteState()
data class Success(
val upvote: Boolean,
) : VoteState()
data class Error(
val message: String,
) : VoteState()
}

View file

@ -77,9 +77,13 @@ fun main() {
mediaPlayerHandler.showToast = { type ->
showToast(
when (type) {
ToastType.ExplicitContent -> runBlocking { getString(Res.string.explicit_content_blocked) }
is ToastType.PlayerError ->
ToastType.ExplicitContent -> {
runBlocking { getString(Res.string.explicit_content_blocked) }
}
is ToastType.PlayerError -> {
runBlocking { getString(Res.string.time_out_check_internet_connection_or_change_piped_instance_in_settings, type.error) }
}
},
)
}
@ -121,8 +125,7 @@ fun main() {
},
),
)
}
.diskCachePolicy(CachePolicy.ENABLED)
}.diskCachePolicy(CachePolicy.ENABLED)
.networkCachePolicy(CachePolicy.ENABLED)
.diskCache(
DiskCache
@ -143,8 +146,8 @@ fun main() {
sharedViewModel = sharedViewModel,
onCloseRequest = {
MiniPlayerManager.isOpen = false
}
},
)
}
}
}
}

2
core

@ -1 +1 @@
Subproject commit 036ec2be6b901372b94e5ca4b49efb600fc1df17
Subproject commit c242a70c736f308930a4aee68a9a8188c5bbb0e9