diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 12e8d7c..02bacab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,6 +93,7 @@ dependencies { kapt(libs.room.compiler) implementation(projects.youtubeMusic) + implementation(projects.synchronizedLyrics) coreLibraryDesugaring(libs.desugaring) } diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/16.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/16.json new file mode 100644 index 0000000..9d1e8f2 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/16.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index 043998c..2e8b60b 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -118,9 +118,15 @@ interface Database { @Query("SELECT lyrics FROM Song WHERE id = :songId") fun lyrics(songId: String): Flow + @Query("SELECT synchronizedLyrics FROM Song WHERE id = :songId") + fun synchronizedLyrics(songId: String): Flow + @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 @@ -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) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt index 0c36c20..eab5965 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt @@ -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 ) { diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Lyrics.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Lyrics.kt index 75a47dd..e25489c 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Lyrics.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Lyrics.kt @@ -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(".") + } + + 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() } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt index be87110..62fbf10 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt @@ -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) + } } } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt index f46cd42..4d3f37a 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt @@ -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 > SharedPreferences.getEnum( key: String, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RelaunchableEffect.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RelaunchableEffect.kt index abb7056..db63872 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RelaunchableEffect.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RelaunchableEffect.kt @@ -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) } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SynchronizedLyrics.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SynchronizedLyrics.kt new file mode 100644 index 0000000..5f3c802 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SynchronizedLyrics.kt @@ -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 + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 60fdd8c..9b438d9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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") diff --git a/synchronized-lyrics/.gitignore b/synchronized-lyrics/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/synchronized-lyrics/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/synchronized-lyrics/build.gradle.kts b/synchronized-lyrics/build.gradle.kts new file mode 100644 index 0000000..69a1eb8 --- /dev/null +++ b/synchronized-lyrics/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + kotlin("jvm") +} + +sourceSets.all { + java.srcDir("src/$name/kotlin") +} + +dependencies { + implementation(libs.kotlin.coroutines) + testImplementation(testLibs.junit) +} diff --git a/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/LujjjhLyrics.kt b/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/LujjjhLyrics.kt new file mode 100644 index 0000000..cdf02f1 --- /dev/null +++ b/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/LujjjhLyrics.kt @@ -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? { + 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 + } + } + } +} diff --git a/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/ParseSentences.kt b/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/ParseSentences.kt new file mode 100644 index 0000000..da7cf2e --- /dev/null +++ b/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/ParseSentences.kt @@ -0,0 +1,24 @@ +package it.vfsfitvnm.synchronizedlyrics + +fun parseSentences(text: String): List> { + 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) + } + } +} diff --git a/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/Result.kt b/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/Result.kt new file mode 100644 index 0000000..b08419f --- /dev/null +++ b/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/Result.kt @@ -0,0 +1,10 @@ +package it.vfsfitvnm.synchronizedlyrics + +import kotlin.coroutines.cancellation.CancellationException + +internal fun Result.recoverIfCancelled(): Result? { + return when (exceptionOrNull()) { + is CancellationException -> null + else -> this + } +} diff --git a/synchronized-lyrics/src/test/kotlin/Test.kt b/synchronized-lyrics/src/test/kotlin/Test.kt new file mode 100644 index 0000000..47c0114 --- /dev/null +++ b/synchronized-lyrics/src/test/kotlin/Test.kt @@ -0,0 +1,11 @@ +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class Test { + @Test + @Throws(Exception::class) + fun test() { + runBlocking { + } + } +}