Add synchronized lyrics (#126)
This commit is contained in:
parent
4a16bc6960
commit
194864bcb4
16 changed files with 866 additions and 38 deletions
|
@ -93,6 +93,7 @@ dependencies {
|
|||
kapt(libs.room.compiler)
|
||||
|
||||
implementation(projects.youtubeMusic)
|
||||
implementation(projects.synchronizedLyrics)
|
||||
|
||||
coreLibraryDesugaring(libs.desugaring)
|
||||
}
|
||||
|
|
592
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/16.json
Normal file
592
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/16.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
1
synchronized-lyrics/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
12
synchronized-lyrics/build.gradle.kts
Normal file
12
synchronized-lyrics/build.gradle.kts
Normal file
|
@ -0,0 +1,12 @@
|
|||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
sourceSets.all {
|
||||
java.srcDir("src/$name/kotlin")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlin.coroutines)
|
||||
testImplementation(testLibs.junit)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
11
synchronized-lyrics/src/test/kotlin/Test.kt
Normal file
11
synchronized-lyrics/src/test/kotlin/Test.kt
Normal file
|
@ -0,0 +1,11 @@
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Test
|
||||
|
||||
class Test {
|
||||
@Test
|
||||
@Throws(Exception::class)
|
||||
fun test() {
|
||||
runBlocking {
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue