Add synchronized lyrics (#126)
This commit is contained in:
@@ -118,9 +118,15 @@ interface Database {
|
||||
@Query("SELECT lyrics FROM Song WHERE id = :songId")
|
||||
fun lyrics(songId: String): Flow<String?>
|
||||
|
||||
@Query("SELECT synchronizedLyrics FROM Song WHERE id = :songId")
|
||||
fun synchronizedLyrics(songId: String): Flow<String?>
|
||||
|
||||
@Query("UPDATE Song SET lyrics = :lyrics WHERE id = :songId")
|
||||
fun updateLyrics(songId: String, lyrics: String?): Int
|
||||
|
||||
@Query("UPDATE Song SET synchronizedLyrics = :lyrics WHERE id = :songId")
|
||||
fun updateSynchronizedLyrics(songId: String, lyrics: String?): Int
|
||||
|
||||
@Query("SELECT * FROM Artist WHERE id = :id")
|
||||
fun artist(id: String): Flow<Artist?>
|
||||
|
||||
@@ -344,7 +350,7 @@ interface Database {
|
||||
views = [
|
||||
SortedSongPlaylistMap::class
|
||||
],
|
||||
version = 15,
|
||||
version = 16,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2),
|
||||
@@ -358,6 +364,7 @@ interface Database {
|
||||
AutoMigration(from = 11, to = 12, spec = DatabaseInitializer.From11To12Migration::class),
|
||||
AutoMigration(from = 12, to = 13),
|
||||
AutoMigration(from = 13, to = 14),
|
||||
AutoMigration(from = 15, to = 16),
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
||||
@@ -11,6 +11,7 @@ data class Song(
|
||||
val durationText: String,
|
||||
val thumbnailUrl: String?,
|
||||
val lyrics: String? = null,
|
||||
val synchronizedLyrics: String? = null,
|
||||
val likedAt: Long? = null,
|
||||
val totalPlayTimeMs: Long = 0
|
||||
) {
|
||||
|
||||
@@ -14,15 +14,19 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -36,12 +40,15 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.synchronizedlyrics.LujjjhLyrics
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
@@ -52,14 +59,20 @@ import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
|
||||
import it.vfsfitvnm.vimusic.ui.styling.BlackColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.DarkColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.utils.SynchronizedLyrics
|
||||
import it.vfsfitvnm.vimusic.utils.center
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.isShowingSynchronizedLyricsKey
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import it.vfsfitvnm.vimusic.utils.verticalFadingEdge
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
@Composable
|
||||
fun Lyrics(
|
||||
@@ -68,7 +81,7 @@ fun Lyrics(
|
||||
onDismiss: () -> Unit,
|
||||
size: Dp,
|
||||
mediaMetadataProvider: () -> MediaMetadata,
|
||||
onLyricsUpdate: (String, String) -> Unit,
|
||||
onLyricsUpdate: (Boolean, String, String) -> Unit,
|
||||
nestedScrollConnectionProvider: () -> NestedScrollConnection,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -80,32 +93,45 @@ fun Lyrics(
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
var isLoading by remember(mediaId) {
|
||||
var isShowingSynchronizedLyrics by rememberPreference(isShowingSynchronizedLyricsKey, false)
|
||||
|
||||
var isLoading by remember(mediaId, isShowingSynchronizedLyrics) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var isEditingLyrics by remember(mediaId) {
|
||||
var isEditingLyrics by remember(mediaId, isShowingSynchronizedLyrics) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val lyrics by remember(mediaId) {
|
||||
Database.lyrics(mediaId).distinctUntilChanged().map flowMap@{ lyrics ->
|
||||
var lyrics by remember(mediaId, isShowingSynchronizedLyrics) {
|
||||
mutableStateOf<String?>(".")
|
||||
}
|
||||
|
||||
LaunchedEffect(mediaId, isShowingSynchronizedLyrics) {
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
Database.synchronizedLyrics(mediaId)
|
||||
} else {
|
||||
Database.lyrics(mediaId)
|
||||
}.distinctUntilChanged().map flowMap@{ lyrics ->
|
||||
if (lyrics != null) return@flowMap lyrics
|
||||
|
||||
isLoading = true
|
||||
|
||||
YouTube.next(mediaId, null)?.map { nextResult ->
|
||||
nextResult.lyrics?.text()?.map { newLyrics ->
|
||||
onLyricsUpdate(mediaId, newLyrics ?: "")
|
||||
isLoading = false
|
||||
return@flowMap newLyrics ?: ""
|
||||
}
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
val mediaMetadata = mediaMetadataProvider()
|
||||
LujjjhLyrics.forSong(mediaMetadata.artist?.toString() ?: "", mediaMetadata.title?.toString() ?: "")
|
||||
} else {
|
||||
YouTube.next(mediaId, null)?.map { nextResult -> nextResult.lyrics?.text()?.getOrNull() }
|
||||
}?.map { newLyrics ->
|
||||
onLyricsUpdate(isShowingSynchronizedLyrics, mediaId, newLyrics ?: "")
|
||||
isLoading = false
|
||||
return@flowMap newLyrics ?: ""
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
null
|
||||
}.distinctUntilChanged()
|
||||
}.collectAsState(initial = ".", context = Dispatchers.IO)
|
||||
}.flowOn(Dispatchers.IO).collect { lyrics = it }
|
||||
}
|
||||
|
||||
if (isEditingLyrics) {
|
||||
TextFieldDialog(
|
||||
@@ -119,7 +145,12 @@ fun Lyrics(
|
||||
},
|
||||
onDone = {
|
||||
query {
|
||||
Database.updateLyrics(mediaId, it)
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
Database.updateSynchronizedLyrics(mediaId, it)
|
||||
} else {
|
||||
Database.updateLyrics(mediaId, it)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -146,7 +177,7 @@ fun Lyrics(
|
||||
.align(Alignment.TopCenter)
|
||||
) {
|
||||
BasicText(
|
||||
text = "An error has occurred while fetching the lyrics",
|
||||
text = "An error has occurred while fetching the ${if (isShowingSynchronizedLyrics) "synchronized " else ""}lyrics",
|
||||
style = typography.xs.center.medium.color(BlackColorPalette.text),
|
||||
modifier = Modifier
|
||||
.background(Color.Black.copy(0.4f))
|
||||
@@ -163,7 +194,7 @@ fun Lyrics(
|
||||
.align(Alignment.TopCenter)
|
||||
) {
|
||||
BasicText(
|
||||
text = "Lyrics are not available for this song",
|
||||
text = "${if (isShowingSynchronizedLyrics) "Synchronized l" else "L"}yrics are not available for this song",
|
||||
style = typography.xs.center.medium.color(BlackColorPalette.text),
|
||||
modifier = Modifier
|
||||
.background(Color.Black.copy(0.4f))
|
||||
@@ -187,16 +218,60 @@ fun Lyrics(
|
||||
}
|
||||
} else {
|
||||
lyrics?.let { lyrics ->
|
||||
if (lyrics.isNotEmpty()) {
|
||||
BasicText(
|
||||
text = lyrics,
|
||||
style = typography.xs.center.medium.color(BlackColorPalette.text),
|
||||
modifier = Modifier
|
||||
.nestedScroll(remember { nestedScrollConnectionProvider() })
|
||||
.verticalFadingEdge()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = size / 4, horizontal = 32.dp)
|
||||
)
|
||||
if (lyrics.isNotEmpty() && lyrics != ".") {
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
val density = LocalDensity.current
|
||||
val player = LocalPlayerServiceBinder.current?.player ?: return@AnimatedVisibility
|
||||
|
||||
val synchronizedLyrics = remember(lyrics) {
|
||||
SynchronizedLyrics(lyrics) {
|
||||
player.currentPosition
|
||||
}.also {
|
||||
println("index: ${it.index}")
|
||||
}
|
||||
}
|
||||
|
||||
val lazyListState = rememberLazyListState(synchronizedLyrics.index, with (density) { size.roundToPx() } / 6)
|
||||
|
||||
LaunchedEffect(synchronizedLyrics) {
|
||||
while (isActive) {
|
||||
delay(50)
|
||||
if (synchronizedLyrics.update()) {
|
||||
synchronizedLyrics.sentences.getOrNull(synchronizedLyrics.index)?.first?.let {
|
||||
lazyListState.animateScrollToItem(synchronizedLyrics.index, with (density) { size.roundToPx() } / 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
userScrollEnabled = false,
|
||||
contentPadding = PaddingValues(vertical = size / 2),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.verticalFadingEdge()
|
||||
) {
|
||||
itemsIndexed(items = synchronizedLyrics.sentences) { index, sentence ->
|
||||
BasicText(
|
||||
text = sentence.second,
|
||||
style = typography.xs.center.medium.color(if (index == synchronizedLyrics.index) BlackColorPalette.text else BlackColorPalette.textDisabled),
|
||||
modifier = Modifier
|
||||
.padding(vertical = 4.dp, horizontal = 32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BasicText(
|
||||
text = lyrics,
|
||||
style = typography.xs.center.medium.color(BlackColorPalette.text),
|
||||
modifier = Modifier
|
||||
.nestedScroll(remember { nestedScrollConnectionProvider() })
|
||||
.verticalFadingEdge()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = size / 4, horizontal = 32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val menuState = LocalMenuState.current
|
||||
@@ -210,6 +285,15 @@ fun Lyrics(
|
||||
.clickable {
|
||||
menuState.display {
|
||||
Menu {
|
||||
MenuEntry(
|
||||
icon = R.drawable.time,
|
||||
text = "Show ${if (isShowingSynchronizedLyrics) "static" else "synchronized"} lyrics",
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
isShowingSynchronizedLyrics = !isShowingSynchronizedLyrics
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.pencil,
|
||||
text = "Edit lyrics",
|
||||
@@ -237,11 +321,12 @@ fun Lyrics(
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"No browser app found!",
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
"No browser app found!",
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,11 +104,21 @@ fun Thumbnail(
|
||||
onDismiss = {
|
||||
onShowLyrics(false)
|
||||
},
|
||||
onLyricsUpdate = { mediaId, lyrics ->
|
||||
if (Database.updateLyrics(mediaId, lyrics) == 0) {
|
||||
if (mediaId == mediaItem.mediaId) {
|
||||
Database.insert(mediaItem) { song ->
|
||||
song.copy(lyrics = lyrics)
|
||||
onLyricsUpdate = { areSynchronized, mediaId, lyrics ->
|
||||
if (areSynchronized) {
|
||||
if (Database.updateSynchronizedLyrics(mediaId, lyrics) == 0) {
|
||||
if (mediaId == mediaItem.mediaId) {
|
||||
Database.insert(mediaItem) { song ->
|
||||
song.copy(synchronizedLyrics = lyrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Database.updateLyrics(mediaId, lyrics) == 0) {
|
||||
if (mediaId == mediaItem.mediaId) {
|
||||
Database.insert(mediaItem) { song ->
|
||||
song.copy(lyrics = lyrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const val repeatModeKey = "repeatMode"
|
||||
const val skipSilenceKey = "skipSilence"
|
||||
const val volumeNormalizationKey = "volumeNormalization"
|
||||
const val persistentQueueKey = "persistentQueue"
|
||||
const val isShowingSynchronizedLyricsKey = "isShowingSynchronizedLyrics"
|
||||
|
||||
inline fun <reified T : Enum<T>> SharedPreferences.getEnum(
|
||||
key: String,
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.LaunchedEffectImpl
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.RememberObserver
|
||||
import androidx.compose.runtime.currentComposer
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -21,3 +22,13 @@ fun relaunchableEffect(
|
||||
val launchedEffect = remember(key1) { LaunchedEffectImpl(applyContext, block) }
|
||||
return launchedEffect::onRemembered
|
||||
}
|
||||
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun relaunchableEffect2(
|
||||
key1: Any?,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): RememberObserver {
|
||||
val applyContext = currentComposer.applyCoroutineContext
|
||||
return remember(key1) { LaunchedEffectImpl(applyContext, block) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package it.vfsfitvnm.vimusic.utils
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import it.vfsfitvnm.synchronizedlyrics.parseSentences
|
||||
|
||||
class SynchronizedLyrics(text: String, private val positionProvider: () -> Long) {
|
||||
val sentences = parseSentences(text)
|
||||
|
||||
var index by mutableStateOf(currentIndex)
|
||||
private set
|
||||
|
||||
private val currentIndex: Int
|
||||
get() {
|
||||
var index = -1
|
||||
for (item in sentences) {
|
||||
if (item.first >= positionProvider()) break
|
||||
index++
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
fun update(): Boolean {
|
||||
val newIndex = currentIndex
|
||||
return if (newIndex != index) {
|
||||
index = newIndex
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user