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) kapt(libs.room.compiler)
implementation(projects.youtubeMusic) implementation(projects.youtubeMusic)
implementation(projects.synchronizedLyrics)
coreLibraryDesugaring(libs.desugaring) 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") @Query("SELECT lyrics FROM Song WHERE id = :songId")
fun lyrics(songId: String): Flow<String?> 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") @Query("UPDATE Song SET lyrics = :lyrics WHERE id = :songId")
fun updateLyrics(songId: String, lyrics: String?): Int 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") @Query("SELECT * FROM Artist WHERE id = :id")
fun artist(id: String): Flow<Artist?> fun artist(id: String): Flow<Artist?>
@ -344,7 +350,7 @@ interface Database {
views = [ views = [
SortedSongPlaylistMap::class SortedSongPlaylistMap::class
], ],
version = 15, version = 16,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 1, to = 2),
@ -358,6 +364,7 @@ interface Database {
AutoMigration(from = 11, to = 12, spec = DatabaseInitializer.From11To12Migration::class), AutoMigration(from = 11, to = 12, spec = DatabaseInitializer.From11To12Migration::class),
AutoMigration(from = 12, to = 13), AutoMigration(from = 12, to = 13),
AutoMigration(from = 13, to = 14), AutoMigration(from = 13, to = 14),
AutoMigration(from = 15, to = 16),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)

View file

@ -11,6 +11,7 @@ data class Song(
val durationText: String, val durationText: String,
val thumbnailUrl: String?, val thumbnailUrl: String?,
val lyrics: String? = null, val lyrics: String? = null,
val synchronizedLyrics: String? = null,
val likedAt: Long? = null, val likedAt: Long? = null,
val totalPlayTimeMs: Long = 0 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.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.rememberScrollState
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import com.valentinilk.shimmer.shimmer import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.synchronizedlyrics.LujjjhLyrics
import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState 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.BlackColorPalette
import it.vfsfitvnm.vimusic.ui.styling.DarkColorPalette import it.vfsfitvnm.vimusic.ui.styling.DarkColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.SynchronizedLyrics
import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.color import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.isShowingSynchronizedLyricsKey
import it.vfsfitvnm.vimusic.utils.medium import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.rememberPreference
import it.vfsfitvnm.vimusic.utils.verticalFadingEdge import it.vfsfitvnm.vimusic.utils.verticalFadingEdge
import it.vfsfitvnm.youtubemusic.YouTube import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
@Composable @Composable
fun Lyrics( fun Lyrics(
@ -68,7 +81,7 @@ fun Lyrics(
onDismiss: () -> Unit, onDismiss: () -> Unit,
size: Dp, size: Dp,
mediaMetadataProvider: () -> MediaMetadata, mediaMetadataProvider: () -> MediaMetadata,
onLyricsUpdate: (String, String) -> Unit, onLyricsUpdate: (Boolean, String, String) -> Unit,
nestedScrollConnectionProvider: () -> NestedScrollConnection, nestedScrollConnectionProvider: () -> NestedScrollConnection,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -80,32 +93,45 @@ fun Lyrics(
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
) { ) {
var isLoading by remember(mediaId) { var isShowingSynchronizedLyrics by rememberPreference(isShowingSynchronizedLyricsKey, false)
var isLoading by remember(mediaId, isShowingSynchronizedLyrics) {
mutableStateOf(false) mutableStateOf(false)
} }
var isEditingLyrics by remember(mediaId) { var isEditingLyrics by remember(mediaId, isShowingSynchronizedLyrics) {
mutableStateOf(false) mutableStateOf(false)
} }
val lyrics by remember(mediaId) { var lyrics by remember(mediaId, isShowingSynchronizedLyrics) {
Database.lyrics(mediaId).distinctUntilChanged().map flowMap@{ lyrics -> mutableStateOf<String?>(".")
}
LaunchedEffect(mediaId, isShowingSynchronizedLyrics) {
if (isShowingSynchronizedLyrics) {
Database.synchronizedLyrics(mediaId)
} else {
Database.lyrics(mediaId)
}.distinctUntilChanged().map flowMap@{ lyrics ->
if (lyrics != null) return@flowMap lyrics if (lyrics != null) return@flowMap lyrics
isLoading = true isLoading = true
YouTube.next(mediaId, null)?.map { nextResult -> if (isShowingSynchronizedLyrics) {
nextResult.lyrics?.text()?.map { newLyrics -> val mediaMetadata = mediaMetadataProvider()
onLyricsUpdate(mediaId, newLyrics ?: "") LujjjhLyrics.forSong(mediaMetadata.artist?.toString() ?: "", mediaMetadata.title?.toString() ?: "")
isLoading = false } else {
return@flowMap newLyrics ?: "" YouTube.next(mediaId, null)?.map { nextResult -> nextResult.lyrics?.text()?.getOrNull() }
} }?.map { newLyrics ->
onLyricsUpdate(isShowingSynchronizedLyrics, mediaId, newLyrics ?: "")
isLoading = false
return@flowMap newLyrics ?: ""
} }
isLoading = false isLoading = false
null null
}.distinctUntilChanged() }.flowOn(Dispatchers.IO).collect { lyrics = it }
}.collectAsState(initial = ".", context = Dispatchers.IO) }
if (isEditingLyrics) { if (isEditingLyrics) {
TextFieldDialog( TextFieldDialog(
@ -119,7 +145,12 @@ fun Lyrics(
}, },
onDone = { onDone = {
query { 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) .align(Alignment.TopCenter)
) { ) {
BasicText( 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), style = typography.xs.center.medium.color(BlackColorPalette.text),
modifier = Modifier modifier = Modifier
.background(Color.Black.copy(0.4f)) .background(Color.Black.copy(0.4f))
@ -163,7 +194,7 @@ fun Lyrics(
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
) { ) {
BasicText( 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), style = typography.xs.center.medium.color(BlackColorPalette.text),
modifier = Modifier modifier = Modifier
.background(Color.Black.copy(0.4f)) .background(Color.Black.copy(0.4f))
@ -187,16 +218,60 @@ fun Lyrics(
} }
} else { } else {
lyrics?.let { lyrics -> lyrics?.let { lyrics ->
if (lyrics.isNotEmpty()) { if (lyrics.isNotEmpty() && lyrics != ".") {
BasicText( if (isShowingSynchronizedLyrics) {
text = lyrics, val density = LocalDensity.current
style = typography.xs.center.medium.color(BlackColorPalette.text), val player = LocalPlayerServiceBinder.current?.player ?: return@AnimatedVisibility
modifier = Modifier
.nestedScroll(remember { nestedScrollConnectionProvider() }) val synchronizedLyrics = remember(lyrics) {
.verticalFadingEdge() SynchronizedLyrics(lyrics) {
.verticalScroll(rememberScrollState()) player.currentPosition
.padding(vertical = size / 4, horizontal = 32.dp) }.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 val menuState = LocalMenuState.current
@ -210,6 +285,15 @@ fun Lyrics(
.clickable { .clickable {
menuState.display { menuState.display {
Menu { Menu {
MenuEntry(
icon = R.drawable.time,
text = "Show ${if (isShowingSynchronizedLyrics) "static" else "synchronized"} lyrics",
onClick = {
menuState.hide()
isShowingSynchronizedLyrics = !isShowingSynchronizedLyrics
}
)
MenuEntry( MenuEntry(
icon = R.drawable.pencil, icon = R.drawable.pencil,
text = "Edit lyrics", text = "Edit lyrics",
@ -237,11 +321,12 @@ fun Lyrics(
if (intent.resolveActivity(context.packageManager) != null) { if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent) context.startActivity(intent)
} else { } else {
Toast.makeText( Toast
context, .makeText(
"No browser app found!", context,
Toast.LENGTH_SHORT "No browser app found!",
) Toast.LENGTH_SHORT
)
.show() .show()
} }
} }

View file

@ -104,11 +104,21 @@ fun Thumbnail(
onDismiss = { onDismiss = {
onShowLyrics(false) onShowLyrics(false)
}, },
onLyricsUpdate = { mediaId, lyrics -> onLyricsUpdate = { areSynchronized, mediaId, lyrics ->
if (Database.updateLyrics(mediaId, lyrics) == 0) { if (areSynchronized) {
if (mediaId == mediaItem.mediaId) { if (Database.updateSynchronizedLyrics(mediaId, lyrics) == 0) {
Database.insert(mediaItem) { song -> if (mediaId == mediaItem.mediaId) {
song.copy(lyrics = lyrics) 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 skipSilenceKey = "skipSilence"
const val volumeNormalizationKey = "volumeNormalization" const val volumeNormalizationKey = "volumeNormalization"
const val persistentQueueKey = "persistentQueue" const val persistentQueueKey = "persistentQueue"
const val isShowingSynchronizedLyricsKey = "isShowingSynchronizedLyrics"
inline fun <reified T : Enum<T>> SharedPreferences.getEnum( inline fun <reified T : Enum<T>> SharedPreferences.getEnum(
key: String, key: String,

View file

@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.InternalComposeApi import androidx.compose.runtime.InternalComposeApi
import androidx.compose.runtime.LaunchedEffectImpl import androidx.compose.runtime.LaunchedEffectImpl
import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.RememberObserver
import androidx.compose.runtime.currentComposer import androidx.compose.runtime.currentComposer
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -21,3 +22,13 @@ fun relaunchableEffect(
val launchedEffect = remember(key1) { LaunchedEffectImpl(applyContext, block) } val launchedEffect = remember(key1) { LaunchedEffectImpl(applyContext, block) }
return launchedEffect::onRemembered 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") version("kotlin", "1.7.0")
plugin("kotlin-serialization","org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin") 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-compiler", "1.2.0")
version("compose", "1.3.0-alpha02") version("compose", "1.3.0-alpha02")
@ -62,3 +64,4 @@ include(":compose-routing")
include(":compose-reordering") include(":compose-reordering")
include(":youtube-music") include(":youtube-music")
include(":ktor-client-brotli") 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 {
}
}
}