diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 02bacab..2494a35 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,7 +93,7 @@ dependencies { kapt(libs.room.compiler) implementation(projects.youtubeMusic) - implementation(projects.synchronizedLyrics) + implementation(projects.kugou) coreLibraryDesugaring(libs.desugaring) } 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 77a4140..6f6d1df 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 @@ -44,9 +44,10 @@ 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.C import androidx.media3.common.MediaMetadata import com.valentinilk.shimmer.shimmer -import it.vfsfitvnm.synchronizedlyrics.LujjjhLyrics +import it.vfsfitvnm.kugou.KuGou import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R @@ -73,6 +74,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext @Composable fun Lyrics( @@ -81,6 +83,7 @@ fun Lyrics( onDismiss: () -> Unit, size: Dp, mediaMetadataProvider: () -> MediaMetadata, + durationProvider: () -> Long, onLyricsUpdate: (Boolean, String, String) -> Unit, nestedScrollConnectionProvider: () -> NestedScrollConnection, modifier: Modifier = Modifier @@ -119,7 +122,20 @@ fun Lyrics( if (isShowingSynchronizedLyrics) { val mediaMetadata = mediaMetadataProvider() - LujjjhLyrics.forSong(mediaMetadata.artist?.toString() ?: "", mediaMetadata.title?.toString() ?: "") + var duration = withContext(Dispatchers.Main) { + durationProvider() + } + + while (duration == C.TIME_UNSET) { + delay(100) + duration = withContext(Dispatchers.Main) { + durationProvider() + } + } + + KuGou.lyrics(mediaMetadata.artist?.toString() ?: "", mediaMetadata.title?.toString() ?: "", duration)?.map { + it?.value + } } else { YouTube.next(mediaId, null)?.map { nextResult -> nextResult.lyrics?.text()?.getOrNull() } }?.map { newLyrics -> @@ -224,7 +240,7 @@ fun Lyrics( val player = LocalPlayerServiceBinder.current?.player ?: return@AnimatedVisibility val synchronizedLyrics = remember(lyrics) { - SynchronizedLyrics(lyrics) { + SynchronizedLyrics(KuGou.Lyrics(lyrics).sentences) { player.currentPosition } } @@ -285,7 +301,8 @@ fun Lyrics( Menu { MenuEntry( icon = R.drawable.time, - text = "Show ${if (isShowingSynchronizedLyrics) "static" else "synchronized"} lyrics", + text = "Show ${if (isShowingSynchronizedLyrics) "un" else ""}synchronized lyrics", + secondaryText = if (isShowingSynchronizedLyrics) null else "Provided by kugou.com", onClick = { menuState.hide() isShowingSynchronizedLyrics = !isShowingSynchronizedLyrics @@ -336,7 +353,11 @@ fun Lyrics( onClick = { menuState.hide() query { - Database.updateLyrics(mediaId, null) + if (isShowingSynchronizedLyrics) { + Database.updateSynchronizedLyrics(mediaId, null) + } else { + Database.updateLyrics(mediaId, null) + } } } ) 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 62fbf10..ea3235c 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 @@ -125,6 +125,7 @@ fun Thumbnail( }, size = thumbnailSizeDp, mediaMetadataProvider = mediaItem::mediaMetadata, + durationProvider = player::getDuration, nestedScrollConnectionProvider = nestedScrollConnectionProvider, ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SynchronizedLyrics.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SynchronizedLyrics.kt index 2b29de7..d7dc6ef 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SynchronizedLyrics.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/SynchronizedLyrics.kt @@ -3,11 +3,8 @@ 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) +class SynchronizedLyrics(val sentences: List>, private val positionProvider: () -> Long) { var index by mutableStateOf(currentIndex) private set diff --git a/synchronized-lyrics/.gitignore b/kugou/.gitignore similarity index 100% rename from synchronized-lyrics/.gitignore rename to kugou/.gitignore diff --git a/kugou/build.gradle.kts b/kugou/build.gradle.kts new file mode 100644 index 0000000..709f0a3 --- /dev/null +++ b/kugou/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + kotlin("jvm") + @Suppress("DSL_SCOPE_VIOLATION") + alias(libs.plugins.kotlin.serialization) +} + +sourceSets.all { + java.srcDir("src/$name/kotlin") +} + +dependencies { + implementation(libs.kotlin.coroutines) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.encoding) + implementation(libs.ktor.client.serialization) + implementation(libs.ktor.serialization.json) + + testImplementation(testLibs.junit) +} diff --git a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/KuGou.kt b/kugou/src/main/kotlin/it/vfsfitvnm/kugou/KuGou.kt new file mode 100644 index 0000000..f2e76e9 --- /dev/null +++ b/kugou/src/main/kotlin/it/vfsfitvnm/kugou/KuGou.kt @@ -0,0 +1,190 @@ +package it.vfsfitvnm.kugou + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.BrowserUserAgent +import io.ktor.client.plugins.compression.ContentEncoding +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.http.ContentType +import io.ktor.http.encodeURLParameter +import io.ktor.serialization.kotlinx.json.json +import io.ktor.util.decodeBase64String +import it.vfsfitvnm.kugou.models.DownloadLyricsResponse +import it.vfsfitvnm.kugou.models.SearchLyricsResponse +import it.vfsfitvnm.kugou.models.SearchSongResponse +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json + +object KuGou { + @OptIn(ExperimentalSerializationApi::class) + private val client by lazy { + HttpClient(CIO) { + BrowserUserAgent() + + expectSuccess = true + + install(ContentNegotiation) { + val feature = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + } + + json(feature) + json(feature, ContentType.Text.Html) + json(feature, ContentType.Text.Plain) + } + + install(ContentEncoding) { + gzip() + deflate() + } + + defaultRequest { + url("https://krcs.kugou.com") + } + } + } + + suspend fun lyrics(artist: String, title: String, duration: Long): Result? { + return runCatching { + for (info in searchSong(keyword(artist, title))) { + if (info.duration >= duration / 1000 - 2 && info.duration <= duration / 1000 + 2) { + searchLyrics(info.hash).firstOrNull()?.let { candidate -> + return@runCatching downloadLyrics( + candidate.id, + candidate.accessKey + ).normalize(title) + } + } + } + + null + }.recoverIfCancelled() + } + + private suspend fun downloadLyrics(id: Long, accessKey: String): Lyrics { + return client.get("/download") { + parameter("ver", 1) + parameter("man", "yes") + parameter("client", "pc") + parameter("fmt", "lrc") + parameter("id", id) + parameter("accesskey", accessKey) + }.body().content.decodeBase64String().let(::Lyrics) + } + + private suspend fun searchLyrics(hash: String): List { + return client.get("/search") { + parameter("ver", 1) + parameter("man", "yes") + parameter("client", "mobi") + parameter("hash", hash) + }.body().candidates + } + + private suspend fun searchSong(keyword: String): List { + return client.get("https://mobileservice.kugou.com/api/v3/search/song") { + parameter("version", 9108) + parameter("plat", 0) + parameter("pagesize", 5) + parameter("showtype", 0) + url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) + }.body().data.info + } + + private fun keyword(artist: String, title: String): String { + val (newTitle, featuring) = title.extract(" (feat. ", ')') + + val newArtist = (if (featuring.isEmpty()) artist else "$artist, $featuring") + .replace(", ", "、") + .replace(" & ", "、") + .replace(".", "") + + return "$newArtist - $newTitle" + } + + private fun String.extract(startDelimiter: String, endDelimiter: Char): Pair { + val startIndex = indexOf(startDelimiter) + + if (startIndex == -1) return this to "" + + val endIndex = indexOf(endDelimiter, startIndex) + + if (endIndex == -1) return this to "" + + return removeRange(startIndex, endIndex + 1) to substring(startIndex + startDelimiter.length, endIndex) + } + + @JvmInline + value class Lyrics(val value: String) : CharSequence by value { + val sentences: List> + get() = mutableListOf(0L to "").apply { + for (line in value.trim().lines()) { + try { + 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 line.substring(10)) + } catch (_: Throwable) { + } + } + } + + fun normalize(title: String): Lyrics { + var toDrop = 0 + var maybeToDrop = 0 + + val text = value.replace("\r\n", "\n").trim() + + for (line in text.lineSequence()) { + if (line.startsWith("[ti:") || + line.startsWith("[ar:") || + line.startsWith("[al:") || + line.startsWith("[by:") || + line.startsWith("[hash:") || + line.startsWith("[sign:") || + line.startsWith("[qq:") || + line.startsWith("[total:") || + line.startsWith("[offset:") || + line.startsWith("[id:") || + line.containsAt("]Written by:", 9) || + line.containsAt("]Lyrics by:", 9) || + line.containsAt("]Composed by:", 9) || + line.containsAt("]Producer:", 9) || + line.containsAt("]作曲 : ", 9) || + line.containsAt("]作词 : ", 9) || + line.containsAt("]$title", 9) + ) { + toDrop += line.length + 1 + maybeToDrop + maybeToDrop = 0 + } else { + if (maybeToDrop == 0) { + maybeToDrop = line.length + 1 + } else { + maybeToDrop = 0 + break + } + } + } + + return Lyrics(text.drop(toDrop + maybeToDrop).removeHtmlEntities()) + } + + private fun String.containsAt(charSequence: CharSequence, startIndex: Int): Boolean = + regionMatches(startIndex, charSequence, 0, charSequence.length) + + private fun String.removeHtmlEntities(): String = + replace("'", "'") + } +} diff --git a/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/Result.kt b/kugou/src/main/kotlin/it/vfsfitvnm/kugou/Result.kt similarity index 85% rename from synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/Result.kt rename to kugou/src/main/kotlin/it/vfsfitvnm/kugou/Result.kt index b08419f..7d89d5c 100644 --- a/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/Result.kt +++ b/kugou/src/main/kotlin/it/vfsfitvnm/kugou/Result.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.synchronizedlyrics +package it.vfsfitvnm.kugou import kotlin.coroutines.cancellation.CancellationException diff --git a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/DownloadLyricsResponse.kt b/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/DownloadLyricsResponse.kt new file mode 100644 index 0000000..049b482 --- /dev/null +++ b/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/DownloadLyricsResponse.kt @@ -0,0 +1,8 @@ +package it.vfsfitvnm.kugou.models + +import kotlinx.serialization.Serializable + +@Serializable +internal class DownloadLyricsResponse( + val content: String +) diff --git a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchLyricsResponse.kt b/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchLyricsResponse.kt new file mode 100644 index 0000000..342b867 --- /dev/null +++ b/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchLyricsResponse.kt @@ -0,0 +1,16 @@ +package it.vfsfitvnm.kugou.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal class SearchLyricsResponse( + val candidates: List +) { + @Serializable + internal class Candidate( + val id: Long, + @SerialName("accesskey") val accessKey: String, + val duration: Long + ) +} diff --git a/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchSongResponse.kt b/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchSongResponse.kt new file mode 100644 index 0000000..97d9dbd --- /dev/null +++ b/kugou/src/main/kotlin/it/vfsfitvnm/kugou/models/SearchSongResponse.kt @@ -0,0 +1,19 @@ +package it.vfsfitvnm.kugou.models + +import kotlinx.serialization.Serializable + +@Serializable +internal data class SearchSongResponse( + val data: Data +) { + @Serializable + internal data class Data( + val info: List + ) { + @Serializable + internal data class Info( + val duration: Long, + val hash: String + ) + } +} diff --git a/synchronized-lyrics/src/test/kotlin/Test.kt b/kugou/src/test/kotlin/Test.kt similarity index 100% rename from synchronized-lyrics/src/test/kotlin/Test.kt rename to kugou/src/test/kotlin/Test.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index 9b438d9..757fc0d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,4 +64,4 @@ include(":compose-routing") include(":compose-reordering") include(":youtube-music") include(":ktor-client-brotli") -include(":synchronized-lyrics") +include(":kugou") diff --git a/synchronized-lyrics/build.gradle.kts b/synchronized-lyrics/build.gradle.kts deleted file mode 100644 index 69a1eb8..0000000 --- a/synchronized-lyrics/build.gradle.kts +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index cdf02f1..0000000 --- a/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/LujjjhLyrics.kt +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index da7cf2e..0000000 --- a/synchronized-lyrics/src/main/kotlin/it/vfsfitvnm/synchronizedlyrics/ParseSentences.kt +++ /dev/null @@ -1,24 +0,0 @@ -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) - } - } -}