Add synchronized lyrics (#126)

This commit is contained in:
vfsfitvnm 2022-08-03 21:08:40 +02:00
parent 4a16bc6960
commit 194864bcb4
16 changed files with 866 additions and 38 deletions

View file

@ -93,6 +93,7 @@ dependencies {
kapt(libs.room.compiler)
implementation(projects.youtubeMusic)
implementation(projects.synchronizedLyrics)
coreLibraryDesugaring(libs.desugaring)
}

View file

@ -0,0 +1,592 @@
{
"formatVersion": 1,
"database": {
"version": 16,
"identityHash": "0cbca5b4016755ebf227461349581201",
"entities": [
{
"tableName": "Song",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistsText",
"columnName": "artistsText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "durationText",
"columnName": "durationText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lyrics",
"columnName": "lyrics",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "synchronizedLyrics",
"columnName": "synchronizedLyrics",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "likedAt",
"columnName": "likedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "totalPlayTimeMs",
"columnName": "totalPlayTimeMs",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "SongPlaylistMap",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"songId",
"playlistId"
]
},
"indices": [
{
"name": "index_SongPlaylistMap_songId",
"unique": false,
"columnNames": [
"songId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)"
},
{
"name": "index_SongPlaylistMap_playlistId",
"unique": false,
"columnNames": [
"playlistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
}
],
"foreignKeys": [
{
"table": "Song",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"songId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Playlist",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"playlistId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Playlist",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Artist",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "info",
"columnName": "info",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "shuffleVideoId",
"columnName": "shuffleVideoId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "shufflePlaylistId",
"columnName": "shufflePlaylistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "radioVideoId",
"columnName": "radioVideoId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "radioPlaylistId",
"columnName": "radioPlaylistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "SongArtistMap",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"songId",
"artistId"
]
},
"indices": [
{
"name": "index_SongArtistMap_songId",
"unique": false,
"columnNames": [
"songId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)"
},
{
"name": "index_SongArtistMap_artistId",
"unique": false,
"columnNames": [
"artistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)"
}
],
"foreignKeys": [
{
"table": "Song",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"songId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Artist",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"artistId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Album",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "authorsText",
"columnName": "authorsText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "shareUrl",
"columnName": "shareUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "SongAlbumMap",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"songId",
"albumId"
]
},
"indices": [
{
"name": "index_SongAlbumMap_songId",
"unique": false,
"columnNames": [
"songId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)"
},
{
"name": "index_SongAlbumMap_albumId",
"unique": false,
"columnNames": [
"albumId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)"
}
],
"foreignKeys": [
{
"table": "Song",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"songId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Album",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"albumId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "SearchQuery",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "query",
"columnName": "query",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_SearchQuery_query",
"unique": true,
"columnNames": [
"query"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)"
}
],
"foreignKeys": []
},
{
"tableName": "QueuedMediaItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaItem",
"columnName": "mediaItem",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Format",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itag",
"columnName": "itag",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "bitrate",
"columnName": "bitrate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "contentLength",
"columnName": "contentLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastModified",
"columnName": "lastModified",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "loudnessDb",
"columnName": "loudnessDb",
"affinity": "REAL",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"songId"
]
},
"indices": [],
"foreignKeys": [
{
"table": "Song",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"songId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [
{
"viewName": "SortedSongPlaylistMap",
"createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position"
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0cbca5b4016755ebf227461349581201')"
]
}
}

View file

@ -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)

View file

@ -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
) {

View file

@ -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()
}
}

View file

@ -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)
}
}
}
}

View file

@ -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,

View file

@ -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) }
}

View file

@ -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
}
}
}

View file

@ -13,6 +13,8 @@ dependencyResolutionManagement {
version("kotlin", "1.7.0")
plugin("kotlin-serialization","org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin")
library("kotlin-coroutines","org.jetbrains.kotlinx", "kotlinx-coroutines-core").version("1.6.4")
version("compose-compiler", "1.2.0")
version("compose", "1.3.0-alpha02")
@ -62,3 +64,4 @@ include(":compose-routing")
include(":compose-reordering")
include(":youtube-music")
include(":ktor-client-brotli")
include(":synchronized-lyrics")

1
synchronized-lyrics/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,12 @@
plugins {
kotlin("jvm")
}
sourceSets.all {
java.srcDir("src/$name/kotlin")
}
dependencies {
implementation(libs.kotlin.coroutines)
testImplementation(testLibs.junit)
}

View file

@ -0,0 +1,26 @@
package it.vfsfitvnm.synchronizedlyrics
import java.io.FileNotFoundException
import java.net.URL
import java.net.URLEncoder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object LujjjhLyrics {
suspend fun forSong(artist: String, title: String): Result<String?>? {
return withContext(Dispatchers.IO) {
runCatching {
val artistParameter = URLEncoder.encode(artist, "UTF-8")
val titleParameter = URLEncoder.encode(title, "UTF-8")
URL("https://lyrics-api.lujjjh.com?artist=$artistParameter&name=$titleParameter")
.openConnection()
.getInputStream()
.bufferedReader()
.readText()
}.recoverIfCancelled()?.recoverCatching { throwable ->
if (throwable is FileNotFoundException) null else throw throwable
}
}
}
}

View file

@ -0,0 +1,24 @@
package it.vfsfitvnm.synchronizedlyrics
fun parseSentences(text: String): List<Pair<Long, String>> {
return mutableListOf(0L to "").apply {
for (line in text.trim().lines()) {
val sentence = line.substring(10)
if (sentence.startsWith(" 作词 : ") || sentence.startsWith(" 作曲 : ")) {
continue
}
val position = line.take(10).run {
get(8).digitToInt() * 10L +
get(7).digitToInt() * 100 +
get(5).digitToInt() * 1000 +
get(4).digitToInt() * 10000 +
get(2).digitToInt() * 60 * 1000 +
get(1).digitToInt() * 600 * 1000
}
add(position to sentence)
}
}
}

View file

@ -0,0 +1,10 @@
package it.vfsfitvnm.synchronizedlyrics
import kotlin.coroutines.cancellation.CancellationException
internal fun <T> Result<T>.recoverIfCancelled(): Result<T>? {
return when (exceptionOrNull()) {
is CancellationException -> null
else -> this
}
}

View file

@ -0,0 +1,11 @@
import kotlinx.coroutines.runBlocking
import org.junit.Test
class Test {
@Test
@Throws(Exception::class)
fun test() {
runBlocking {
}
}
}