From 9fd55046b30a19679034435ce47b6886ff6dcfa2 Mon Sep 17 00:00:00 2001
From: CosmicIsAryan <146110938+CosmicIsAryan@users.noreply.github.com>
Date: Mon, 19 Oct 2026 10:42:18 +0530
Subject: [PATCH 1/4] Fix Spotify networking, lyric timing and library
reliability
---
.gitignore | 4 +-
NOTICE | 12 +
app/build.gradle.kts | 4 +-
app/src/androidTest/AndroidManifest.xml | 7 +
.../network/NetworkSecurityInstrumentation.kt | 63 +
app/src/main/kotlin/com/convx/music/App.kt | 1 +
.../kotlin/com/convx/music/AppNavItems.kt | 2 +-
.../kotlin/com/convx/music/AppRootContent.kt | 13 +-
.../kotlin/com/convx/music/ai/AiChatClient.kt | 56 +-
.../artistvideo/ArtistVideoCanvasProvider.kt | 4 +-
.../convx/music/constants/PreferenceKeys.kt | 3 +
.../convx/music/eq/data/EQProfileImport.kt | 25 +
.../com/convx/music/lyrics/LyricsFont.kt | 28 +
.../com/convx/music/lyrics/LyricsHelper.kt | 82 +-
.../music/lyrics/LyricsProviderRepository.kt | 19 +-
.../music/lyrics/LyricsProviderSettings.kt | 25 +-
.../convx/music/lyrics/LyricsTextSpacing.kt | 41 +
.../com/convx/music/playback/DownloadUtil.kt | 3 +
.../com/convx/music/playback/MusicService.kt | 62 +-
.../recognition/MusicRecognitionService.kt | 8 +-
.../convx/music/ui/component/ConvxLyrics.kt | 32 +-
.../component/DraggableLyricsProviderList.kt | 12 +-
.../music/ui/component/FlamingoLyrics.kt | 85 ++
.../com/convx/music/ui/component/Lyrics.kt | 160 +--
.../music/ui/component/LyricsFollowScroll.kt | 29 +
.../music/ui/component/LyricsKaraokeView.kt | 15 +-
.../ui/component/LyricsProviderSelector.kt | 5 +
.../music/ui/component/LyricsStyleLabel.kt | 23 +
.../com/convx/music/ui/component/LyricsV2.kt | 22 +-
.../convx/music/ui/component/MetroLyrics.kt | 12 +-
.../music/ui/component/NewMenuComponents.kt | 40 +-
.../component/flamingo/FlamingoLyricView.kt | 1077 +++++++++++++++++
.../ui/component/flamingo/YosMediaEvent.kt | 8 +
.../ui/component/flamingo/YosUIConfig.kt | 21 +
.../music/ui/component/flamingo/YosWrapper.kt | 8 +
.../com/convx/music/ui/menu/SongMenu.kt | 8 +
.../music/ui/player/FloatingMiniPlayer.kt | 22 +-
.../com/convx/music/ui/player/Player.kt | 48 +-
.../convx/music/ui/player/PlayerV2Canvas.kt | 52 +-
.../com/convx/music/ui/player/Player_v2.kt | 64 +-
.../music/ui/screens/NavigationBuilder.kt | 10 +
.../screens/equalizer/axion/AxionEqScreen.kt | 27 +
.../equalizer/axion/AxionEqViewModel.kt | 46 +
.../ui/screens/library/LocalFoldersScreen.kt | 75 ++
.../ui/screens/library/LocalMusicScreen.kt | 8 +
.../ui/screens/library/LocalMusicViewModel.kt | 13 +
.../ui/screens/library/LocalSongsScreen.kt | 6 +
.../ui/screens/library/SongInsightScreen.kt | 52 +
.../library/apple/AppleLibrarySubscreens.kt | 5 +
.../ui/screens/playlist/AutoPlaylistScreen.kt | 30 +-
.../screens/recognition/RecognitionScreen.kt | 26 +-
.../settings/AppearanceAdvancedSettings.kt | 4 +-
.../ui/screens/settings/AppearanceSettings.kt | 36 +-
.../settings/ContentAdvancedSettings.kt | 44 +-
.../ui/screens/settings/LyricsFontSettings.kt | 45 +
.../screens/settings/LyricsSettingsScreen.kt | 53 +
.../ui/screens/settings/ModuleSourceScreen.kt | 46 +-
.../music/ui/screens/settings/SettingsHubs.kt | 2 +-
.../ui/screens/settings/SpotifyScreen.kt | 43 +-
.../com/convx/music/ui/utils/ModifierExt.kt | 8 +-
.../music/utils/AudioFormatCandidates.kt | 21 +
.../music/utils/ModulePlaybackResolver.kt | 6 +-
.../com/convx/music/utils/YTPlayerUtils.kt | 84 +-
.../viewmodels/LyricsAppearanceViewModel.kt | 81 ++
.../viewmodels/LyricsProviderViewModel.kt | 9 +-
.../music/viewmodels/SongInsightViewModel.kt | 100 ++
.../viewmodels/SpotifyImportViewModel.kt | 30 +-
.../convx/music/widget/ConvxWidgetManager.kt | 8 +-
.../convx/music/widget/MusicWidgetReceiver.kt | 28 +-
.../music/widget/NowPlayingWidgetReceiver.kt | 10 +-
.../music/widget/TurntableWidgetReceiver.kt | 16 +-
app/src/main/res/values/strings.xml | 13 +
.../main/res/xml/network_security_config.xml | 9 +-
.../com/convx/music/eq/EQProfileImportTest.kt | 26 +
.../music/lyrics/LyricsTextSpacingTest.kt | 41 +
.../ui/component/LyricsFollowScrollTest.kt | 15 +
.../music/utils/AudioFormatCandidatesTest.kt | 44 +
canvas/build.gradle.kts | 2 +-
.../applecanvas/AppleMusicCanvasProvider.kt | 4 +-
.../AppleMusicArtistBackgroundProvider.kt | 4 +-
.../convx/music/canvas/AppleMusicCatalog.kt | 4 +-
.../convx/music/canvas/TidalCanvasProvider.kt | 4 +-
.../models/response/PlayerResponse.kt | 4 +-
integrations/build.gradle.kts | 1 -
.../kotlin/com/music/jiosaavn/SaavnService.kt | 6 +-
.../main/kotlin/com/music/lastfm/LastFM.kt | 4 +-
.../main/kotlin/com/music/shazamkit/Shazam.kt | 9 +-
lyrics/build.gradle.kts | 2 +-
.../convx/music/betterlyrics/BetterLyrics.kt | 4 +-
.../music/lyrics/BitChordLyricsProvider.kt | 64 +
.../music/lyrics/LyricsProviderRegistry.kt | 48 +-
.../com/convx/music/lyrics/LyricsSyncLevel.kt | 57 +
.../music/lyrics/bitchord/BackgroundVocals.kt | 106 ++
.../music/lyrics/bitchord/BetterLyrics.kt | 60 +
.../convx/music/lyrics/bitchord/BiniLyrics.kt | 126 ++
.../music/lyrics/bitchord/EnhancedLrc.kt | 96 ++
.../com/convx/music/lyrics/bitchord/Genius.kt | 357 ++++++
.../com/convx/music/lyrics/bitchord/KuGou.kt | 159 +++
.../com/convx/music/lyrics/bitchord/LrcLib.kt | 209 ++++
.../com/convx/music/lyrics/bitchord/LrcRed.kt | 179 +++
.../convx/music/lyrics/bitchord/LrcWriter.kt | 178 +++
.../music/lyrics/bitchord/LyricAlignments.kt | 74 ++
.../convx/music/lyrics/bitchord/LyricGaps.kt | 35 +
.../convx/music/lyrics/bitchord/LyricLine.kt | 654 ++++++++++
.../music/lyrics/bitchord/LyricSections.kt | 11 +
.../convx/music/lyrics/bitchord/LyricsHttp.kt | 83 ++
.../convx/music/lyrics/bitchord/LyricsPlus.kt | 229 ++++
.../music/lyrics/bitchord/LyricsQuery.kt | 60 +
.../music/lyrics/bitchord/LyricsSource.kt | 155 +++
.../convx/music/lyrics/bitchord/Megalobiz.kt | 30 +
.../convx/music/lyrics/bitchord/Musixmatch.kt | 362 ++++++
.../convx/music/lyrics/bitchord/PaxSenix.kt | 361 ++++++
.../lyrics/bitchord/ProviderHttpClient.kt | 10 +
.../music/lyrics/bitchord/ProviderLyrics.kt | 128 ++
.../music/lyrics/bitchord/SimpMusicLyrics.kt | 66 +
.../music/lyrics/bitchord/SyllableRuns.kt | 80 ++
.../convx/music/lyrics/bitchord/TtmlLyrics.kt | 309 +++++
.../com/convx/music/lyrics/bitchord/Unison.kt | 104 ++
.../music/musixmatch/MusixmatchClient.kt | 4 +-
.../main/kotlin/com/music/lrclib/LrcLib.kt | 4 +-
.../kotlin/com/music/paxsenix/Paxsenix.kt | 4 +-
.../com/music/simpmusic/SimpMusicLyrics.kt | 4 +-
.../kotlin/com/music/youlyplus/YouLyPlus.kt | 4 +-
.../music/lyrics/BitChordLyricsFormatTest.kt | 19 +
.../lyrics/LyricsProviderRegistryTest.kt | 8 +
.../convx/music/lyrics/LyricsSyncLevelTest.kt | 28 +
.../kotlin/com/music/spine/EclipseAddon.kt | 94 +-
.../kotlin/com/music/spine/ModuleManager.kt | 39 +-
.../kotlin/com/music/spine/ModuleResults.kt | 1 +
.../kotlin/com/music/spine/SpineModule.kt | 1 +
.../com/music/spine/EclipseAddonTest.kt | 22 +
spotify/build.gradle.kts | 2 +-
.../main/kotlin/com/music/spotify/Spotify.kt | 29 +-
.../kotlin/com/music/spotify/SpotifyAuth.kt | 275 ++---
.../com/music/spotify/SpotifyHashProvider.kt | 40 +-
.../spotify/SpotifyWebPlayerTokenTest.kt | 21 +
136 files changed, 7603 insertions(+), 759 deletions(-)
create mode 100644 app/src/androidTest/AndroidManifest.xml
create mode 100644 app/src/androidTest/kotlin/com/convx/music/network/NetworkSecurityInstrumentation.kt
create mode 100644 app/src/main/kotlin/com/convx/music/eq/data/EQProfileImport.kt
create mode 100644 app/src/main/kotlin/com/convx/music/lyrics/LyricsFont.kt
create mode 100644 app/src/main/kotlin/com/convx/music/lyrics/LyricsTextSpacing.kt
create mode 100644 app/src/main/kotlin/com/convx/music/ui/component/FlamingoLyrics.kt
create mode 100644 app/src/main/kotlin/com/convx/music/ui/component/LyricsFollowScroll.kt
create mode 100644 app/src/main/kotlin/com/convx/music/ui/component/LyricsStyleLabel.kt
create mode 100644 app/src/main/kotlin/com/convx/music/ui/component/flamingo/FlamingoLyricView.kt
create mode 100644 app/src/main/kotlin/com/convx/music/ui/component/flamingo/YosMediaEvent.kt
create mode 100644 app/src/main/kotlin/com/convx/music/ui/component/flamingo/YosUIConfig.kt
create mode 100644 app/src/main/kotlin/com/convx/music/ui/component/flamingo/YosWrapper.kt
create mode 100644 app/src/main/kotlin/com/convx/music/ui/screens/library/LocalFoldersScreen.kt
create mode 100644 app/src/main/kotlin/com/convx/music/ui/screens/library/SongInsightScreen.kt
create mode 100644 app/src/main/kotlin/com/convx/music/ui/screens/settings/LyricsFontSettings.kt
create mode 100644 app/src/main/kotlin/com/convx/music/utils/AudioFormatCandidates.kt
create mode 100644 app/src/main/kotlin/com/convx/music/viewmodels/LyricsAppearanceViewModel.kt
create mode 100644 app/src/main/kotlin/com/convx/music/viewmodels/SongInsightViewModel.kt
create mode 100644 app/src/test/kotlin/com/convx/music/eq/EQProfileImportTest.kt
create mode 100644 app/src/test/kotlin/com/convx/music/lyrics/LyricsTextSpacingTest.kt
create mode 100644 app/src/test/kotlin/com/convx/music/ui/component/LyricsFollowScrollTest.kt
create mode 100644 app/src/test/kotlin/com/convx/music/utils/AudioFormatCandidatesTest.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/BitChordLyricsProvider.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/LyricsSyncLevel.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/BackgroundVocals.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/BetterLyrics.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/BiniLyrics.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/EnhancedLrc.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/Genius.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/KuGou.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/LrcLib.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/LrcRed.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/LrcWriter.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/LyricAlignments.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/LyricGaps.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/LyricLine.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/LyricSections.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/LyricsHttp.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/LyricsPlus.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/LyricsQuery.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/LyricsSource.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/Megalobiz.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/Musixmatch.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/PaxSenix.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/ProviderHttpClient.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/ProviderLyrics.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/SimpMusicLyrics.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/SyllableRuns.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/TtmlLyrics.kt
create mode 100644 lyrics/src/main/kotlin/com/convx/music/lyrics/bitchord/Unison.kt
create mode 100644 lyrics/src/test/kotlin/com/convx/music/lyrics/BitChordLyricsFormatTest.kt
create mode 100644 lyrics/src/test/kotlin/com/convx/music/lyrics/LyricsSyncLevelTest.kt
create mode 100644 spotify/src/test/kotlin/com/music/spotify/SpotifyWebPlayerTokenTest.kt
diff --git a/.gitignore b/.gitignore
index 13df3ed5..4e80f718 100644
--- a/.gitignore
+++ b/.gitignore
@@ -137,9 +137,9 @@ NEW-UI/
# Reference checkouts read during the BitChord/ArchiveTune parity work.
# Cloned repos with their own .git — never ours to commit.
-BitChord/
+/BitChord/
ArchiveTune/
-Flamingo/
+/Flamingo/
News/
fastlane/
merge_strings.py
diff --git a/NOTICE b/NOTICE
index ea546f9b..18ecd90c 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,5 +1,17 @@
Convx bundles source vendored from third-party open-source projects.
+## Lyrics providers
+
+The provider clients and lyric parsers in lyrics/.../lyrics/bitchord are
+adapted from BitChord (https://github.com/kushagrasinghx/BitChord),
+Copyright its contributors, licensed under GPL-3.0. See LICENSE.
+
+## Flamingo lyrics and player controls
+
+The renderer in app/.../ui/component/flamingo and player chrome values
+are adapted from Flamingo (https://github.com/shouryadixitisverycool/Flamingo),
+by Yos-X and contributors, licensed under GPL-3.0. See LICENSE.
+
## hig module
Portions of the `hig/` module are vendored from compose-hig
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6ab3a33a..81c05019 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -39,7 +39,8 @@ android {
versionCode = appVersionCode
versionName = appVersionName
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunner = providers.gradleProperty("convxTestRunner")
+ .orElse("androidx.test.runner.AndroidJUnitRunner").get()
vectorDrawables.useSupportLibrary = true
// LastFM API keys from local.properties or environment variables
@@ -354,7 +355,6 @@ dependencies {
implementation(libs.ktor.client.core)
- implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.json)
diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml
new file mode 100644
index 00000000..fb9fe581
--- /dev/null
+++ b/app/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/androidTest/kotlin/com/convx/music/network/NetworkSecurityInstrumentation.kt b/app/src/androidTest/kotlin/com/convx/music/network/NetworkSecurityInstrumentation.kt
new file mode 100644
index 00000000..e0630c32
--- /dev/null
+++ b/app/src/androidTest/kotlin/com/convx/music/network/NetworkSecurityInstrumentation.kt
@@ -0,0 +1,63 @@
+package com.convx.music.network
+
+import android.app.Activity
+import android.app.Instrumentation
+import android.os.Bundle
+import android.security.NetworkSecurityPolicy
+import com.convx.music.artistvideo.ArtistVideoCanvasProvider
+import com.convx.music.canvas.AppleMusicCatalog
+import com.music.lastfm.LastFM
+import com.music.lrclib.LrcLib
+import com.music.spotify.Spotify
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.expectSuccess
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsText
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+
+/** Runs against the installed debug app, whose Android trust policy JVM tests cannot exercise. */
+class NetworkSecurityInstrumentation : Instrumentation() {
+ override fun onCreate(arguments: Bundle?) {
+ super.onCreate(arguments)
+ start()
+ }
+
+ override fun onStart() {
+ val report = StringBuilder()
+ try {
+ val policy = NetworkSecurityPolicy.getInstance()
+ check(policy.isCleartextTrafficPermitted("localhost"))
+ check(!policy.isCleartextTrafficPermitted("open.spotify.com"))
+ runBlocking {
+ val targets = listOf(
+ Triple(Spotify, "getGqlClient", "https://api-partner.spotify.com/"),
+ Triple(LrcLib, "getClient", "https://lrclib.net/"),
+ Triple(AppleMusicCatalog, "getClient", "https://amp-api.music.apple.com/"),
+ Triple(LastFM, "getClient", "https://ws.audioscrobbler.com/2.0/"),
+ Triple(ArtistVideoCanvasProvider, "getClient", "https://www.apple.com/"),
+ )
+ for ((provider, getter, url) in targets) {
+ val method = provider.javaClass.declaredMethods.first {
+ it.name == getter || it.name.startsWith(getter + "$")
+ }.apply { isAccessible = true }
+ val client = method.invoke(provider) as HttpClient
+ check(client.engine.javaClass.simpleName == "OkHttpEngine") {
+ "${provider.javaClass.simpleName} must use Android-aware TLS"
+ }
+ withTimeout(20_000) {
+ val response = client.get(url) { expectSuccess = false }
+ // Any HTTP response, including an unauthenticated 401/404, proves TLS completed.
+ response.bodyAsText()
+ report.appendLine("${provider.javaClass.simpleName}: HTTP ${response.status.value}")
+ }
+ }
+ }
+ finish(Activity.RESULT_OK, Bundle().apply { putString("stream", report.toString()) })
+ } catch (failure: Exception) {
+ finish(Activity.RESULT_CANCELED, Bundle().apply {
+ putString("stream", report.toString() + "FAILED: ${failure.javaClass.simpleName}: ${failure.message}")
+ })
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/convx/music/App.kt b/app/src/main/kotlin/com/convx/music/App.kt
index a1726e3f..862dcf45 100644
--- a/app/src/main/kotlin/com/convx/music/App.kt
+++ b/app/src/main/kotlin/com/convx/music/App.kt
@@ -75,6 +75,7 @@ class App : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
super.onCreate()
+ com.music.spotify.SpotifyAuth.initialize(this)
// The recovery activity runs in its own process. Do not reopen the database,
// preferences, native caches or playback integrations that may have crashed.
val processName = if (Build.VERSION.SDK_INT >= 28) {
diff --git a/app/src/main/kotlin/com/convx/music/AppNavItems.kt b/app/src/main/kotlin/com/convx/music/AppNavItems.kt
index 5c7c9b0b..2c9fe384 100644
--- a/app/src/main/kotlin/com/convx/music/AppNavItems.kt
+++ b/app/src/main/kotlin/com/convx/music/AppNavItems.kt
@@ -42,7 +42,7 @@ fun rememberAppNavItems(): AppNavItems {
} else {
screens
}
- visible.filter { it != Screens.Radio || it.route !in hiddenRoutes }
+ visible.filter { it == Screens.Search || it.route !in hiddenRoutes }
}
// Explore is only shown in the floating navigation bar, and is online-only.
val floatingNavigationItems = remember(navigationItems, localOnlyMode, hiddenRoutes) {
diff --git a/app/src/main/kotlin/com/convx/music/AppRootContent.kt b/app/src/main/kotlin/com/convx/music/AppRootContent.kt
index ef0009f2..98fe8905 100644
--- a/app/src/main/kotlin/com/convx/music/AppRootContent.kt
+++ b/app/src/main/kotlin/com/convx/music/AppRootContent.kt
@@ -340,8 +340,8 @@ internal fun AppRootContent(
}
val tabOpenedFromShortcut = remember {
when (activity.intent?.action) {
- MainActivity.ACTION_SEARCH -> NavigationTab.LIBRARY
- MainActivity.ACTION_LIBRARY -> NavigationTab.SEARCH
+ MainActivity.ACTION_SEARCH -> NavigationTab.SEARCH
+ MainActivity.ACTION_LIBRARY -> NavigationTab.LIBRARY
else -> null
}
}
@@ -420,6 +420,15 @@ internal fun AppRootContent(
val navigationItemRoutes = remember(floatingNavigationItems) {
floatingNavigationItems.map { it.route }.toSet()
}
+ LaunchedEffect(currentRoute, navigationItems) {
+ val route = currentRoute ?: return@LaunchedEffect
+ if (route in Screens.MainRoutes && navigationItems.none { it.route == route }) {
+ navController.navigate(navigationItems.first().route) {
+ popUpTo(route) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ }
val isKeyboardOpen = WindowInsets.isImeVisible
val shouldShowNavigationBar = remember(currentRoute) {
diff --git a/app/src/main/kotlin/com/convx/music/ai/AiChatClient.kt b/app/src/main/kotlin/com/convx/music/ai/AiChatClient.kt
index ee0e44d0..273c268a 100644
--- a/app/src/main/kotlin/com/convx/music/ai/AiChatClient.kt
+++ b/app/src/main/kotlin/com/convx/music/ai/AiChatClient.kt
@@ -14,6 +14,16 @@ import com.convx.music.utils.dataStore
import com.convx.music.utils.get
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Response
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+import kotlin.coroutines.resumeWithException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -23,7 +33,7 @@ import org.json.JSONObject
/** Sends chat-completions requests using the configured provider URL, model, and API key. */
object AiChatClient {
- private val client = OkHttpClient()
+ private val client = OkHttpClient.Builder().callTimeout(30, TimeUnit.SECONDS).build()
/** Returns the assistant response, or null if the request fails. */
suspend fun complete(
@@ -62,26 +72,40 @@ object AiChatClient {
})
}.toString().toRequestBody("application/json".toMediaType())
- val request = Request.Builder()
- .url(baseUrl)
- .addHeader("Authorization", "Bearer $apiKey")
- .post(requestBody)
- .build()
-
try {
- val response = client.newCall(request).execute()
- if (!response.isSuccessful) {
- val errorBody = response.body.string().take(300)
- onLog("AI request failed: ${response.code} $errorBody")
- return@withContext null
+ val request = Request.Builder()
+ .url(baseUrl)
+ .addHeader("Authorization", "Bearer $apiKey")
+ .post(requestBody)
+ .build()
+ client.newCall(request).awaitResponse().use { response ->
+ if (!response.isSuccessful) {
+ onLog("AI request failed: HTTP ${response.code}")
+ return@withContext null
+ }
+ val responseString = response.body.string()
+ currentCoroutineContext().ensureActive()
+ val responseJson = JSONObject(responseString)
+ val choices = responseJson.optJSONArray("choices") ?: return@withContext null
+ choices.optJSONObject(0)?.optJSONObject("message")?.optString("content")
}
- val responseString = response.body.string()
- val responseJson = JSONObject(responseString)
- val choices = responseJson.optJSONArray("choices") ?: return@withContext null
- choices.optJSONObject(0)?.optJSONObject("message")?.optString("content")
+ } catch (cancelled: CancellationException) {
+ throw cancelled
} catch (e: Exception) {
onLog("Network error: ${e.message}")
null
}
}
+
+ private suspend fun Call.awaitResponse(): Response = suspendCancellableCoroutine { continuation ->
+ continuation.invokeOnCancellation { cancel() }
+ enqueue(object : Callback {
+ override fun onFailure(call: Call, error: IOException) {
+ if (continuation.isActive) continuation.resumeWithException(error)
+ }
+ override fun onResponse(call: Call, response: Response) {
+ continuation.resume(response) { _, value, _ -> value.close() }
+ }
+ })
+ }
}
diff --git a/app/src/main/kotlin/com/convx/music/artistvideo/ArtistVideoCanvasProvider.kt b/app/src/main/kotlin/com/convx/music/artistvideo/ArtistVideoCanvasProvider.kt
index a491e272..c7493ba6 100644
--- a/app/src/main/kotlin/com/convx/music/artistvideo/ArtistVideoCanvasProvider.kt
+++ b/app/src/main/kotlin/com/convx/music/artistvideo/ArtistVideoCanvasProvider.kt
@@ -2,7 +2,7 @@ package com.convx.music.artistvideo
import io.ktor.client.HttpClient
import io.ktor.client.call.body
-import io.ktor.client.engine.cio.CIO
+import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.cache.HttpCache
import io.ktor.client.plugins.compression.ContentEncoding
@@ -29,7 +29,7 @@ object ArtistVideoCanvasProvider {
}
private val client by lazy {
- HttpClient(CIO) {
+ HttpClient(OkHttp) {
install(ContentNegotiation) { json(json) }
install(HttpTimeout) {
connectTimeoutMillis = 15_000
diff --git a/app/src/main/kotlin/com/convx/music/constants/PreferenceKeys.kt b/app/src/main/kotlin/com/convx/music/constants/PreferenceKeys.kt
index 12266d1f..8dc96745 100644
--- a/app/src/main/kotlin/com/convx/music/constants/PreferenceKeys.kt
+++ b/app/src/main/kotlin/com/convx/music/constants/PreferenceKeys.kt
@@ -776,6 +776,7 @@ val FetchedModulesKey = stringPreferencesKey("fetchedModules")
val LyricsAnimationStyleKey = stringPreferencesKey("lyricsAnimationStyle")
enum class LyricsAnimationStyle {
+ FLAMINGO,
NONE,
FADE,
GLOW,
@@ -798,6 +799,8 @@ enum class LyricsAnimationStyle {
}
val LyricsTextSizeKey = floatPreferencesKey("lyricsTextSize")
+val LyricsFontKey = stringPreferencesKey("lyricsFont")
+val LyricsCustomFontKey = stringPreferencesKey("lyricsCustomFont")
val LyricsLineSpacingKey = floatPreferencesKey("lyricsLineSpacing")
val PlayerVolumeKey = floatPreferencesKey("playerVolume")
diff --git a/app/src/main/kotlin/com/convx/music/eq/data/EQProfileImport.kt b/app/src/main/kotlin/com/convx/music/eq/data/EQProfileImport.kt
new file mode 100644
index 00000000..b8a75377
--- /dev/null
+++ b/app/src/main/kotlin/com/convx/music/eq/data/EQProfileImport.kt
@@ -0,0 +1,25 @@
+package com.convx.music.eq.data
+
+import java.io.InputStream
+
+internal object EQProfileImport {
+ private const val MAX_BYTES = 64 * 1024
+
+ fun read(input: InputStream): ParametricEQ {
+ val bytes = ByteArray(MAX_BYTES + 1)
+ var count = 0
+ while (count < bytes.size) {
+ val read = input.read(bytes, count, bytes.size - count)
+ if (read == -1) break
+ if (read == 0) {
+ val next = input.read()
+ if (next == -1) break
+ bytes[count++] = next.toByte()
+ } else count += read
+ }
+ require(count <= MAX_BYTES) { "EQ file is too large" }
+ val profile = ParametricEQParser.parseText(String(bytes, 0, count, Charsets.UTF_8).removePrefix("\uFEFF"))
+ require(ParametricEQParser.validate(profile).isEmpty()) { "Invalid EQ profile" }
+ return profile
+ }
+}
diff --git a/app/src/main/kotlin/com/convx/music/lyrics/LyricsFont.kt b/app/src/main/kotlin/com/convx/music/lyrics/LyricsFont.kt
new file mode 100644
index 00000000..ad6e5596
--- /dev/null
+++ b/app/src/main/kotlin/com/convx/music/lyrics/LyricsFont.kt
@@ -0,0 +1,28 @@
+package com.convx.music.lyrics
+
+import androidx.compose.runtime.*
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontFamily
+import android.graphics.Typeface
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/** Imported faces are loaded once, away from the UI thread, and reused by every line. */
+@Composable
+fun rememberLyricsFont(selection: String): FontFamily {
+ val context = LocalContext.current
+ val family by produceState(FontFamily.Default, selection) {
+ value = when (selection) {
+ "serif" -> FontFamily.Serif
+ "monospace" -> FontFamily.Monospace
+ "rounded" -> FontFamily(Typeface.create("sans-serif-rounded", Typeface.NORMAL))
+ else -> if (selection.startsWith("custom-")) withContext(Dispatchers.IO) {
+ runCatching {
+ require(selection.matches(Regex("custom-\\d+\\.font")))
+ FontFamily(Typeface.createFromFile(context.filesDir.resolve("lyrics-fonts").resolve(selection)))
+ }.getOrDefault(FontFamily.Default)
+ } else FontFamily.Default
+ }
+ }
+ return family
+}
diff --git a/app/src/main/kotlin/com/convx/music/lyrics/LyricsHelper.kt b/app/src/main/kotlin/com/convx/music/lyrics/LyricsHelper.kt
index b02bf110..b6c83302 100644
--- a/app/src/main/kotlin/com/convx/music/lyrics/LyricsHelper.kt
+++ b/app/src/main/kotlin/com/convx/music/lyrics/LyricsHelper.kt
@@ -17,6 +17,10 @@ import com.convx.music.utils.NetworkConnectivityObserver
import com.convx.music.utils.dataStore
import com.convx.music.utils.reportException
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
@@ -63,7 +67,11 @@ constructor(
private val cache = LruCache>(MAX_CACHE_SIZE)
private var currentLyricsJob: Job? = null
- suspend fun getLyrics(mediaMetadata: MediaMetadata): LyricsWithProvider {
+ suspend fun getLyrics(
+ mediaMetadata: MediaMetadata,
+ initialLyrics: LyricsWithProvider? = null,
+ onUpgrade: suspend (LyricsWithProvider) -> Unit = {},
+ ): LyricsWithProvider {
currentLyricsJob?.cancel()
if (!hasNetworkConnection()) {
@@ -71,31 +79,59 @@ constructor(
}
val providers = resolveLyricsProviders().usableFor(isLocalSong(mediaMetadata.id))
- for (provider in providers) {
- if (!LyricsProviderSettings.isEnabled(context, provider)) continue
-
+ .filter { LyricsProviderSettings.isEnabled(context, it) }
+ return coroutineScope {
+ val policy = LyricsUpgradePolicy()
+ initialLyrics?.takeIf { initial -> providers.any { it.id == initial.provider } }?.let {
+ policy.consider(it.provider, providers.indexOfFirst { p -> p.id == it.provider }, it.lyrics)
+ }
+ val remaining = providers.indices.toMutableList()
+ // Keep one fast line fallback in the first window while the leading
+ // word providers work. No more than three source attempts run at once.
+ val fallback = providers.indexOfFirst { it.id == "LrcLib" }
+ if (fallback > 2) { remaining.remove(fallback); remaining.add(2, fallback) }
+ val answers = Channel>>(Channel.UNLIMITED)
+ val jobs = mutableListOf()
+ val completed = hashSetOf()
+ var inFlight = 0
+ fun fillWindow() {
+ while (inFlight < 3 && remaining.isNotEmpty()) {
+ val bestWord = policy.best?.takeIf { it.sync == LyricsSyncLevel.WORD }
+ val next = if (bestWord == null) 0 else remaining.indexOfFirst { it < bestWord.priority }
+ if (next < 0) break
+ val index = remaining.removeAt(next)
+ inFlight++
+ jobs += launch(Dispatchers.IO) {
+ val result = providerRepository.fetch(providers[index], mediaMetadata)
+ answers.send(index to result)
+ }
+ }
+ }
+ val deadline = System.nanoTime() / 1_000_000 + 20_000L
try {
- val result = withTimeoutOrNull(10_000) {
- providerRepository.fetch(
- provider,
- mediaMetadata.id,
- mediaMetadata.title,
- mediaMetadata.artists.joinToString { it.name },
- mediaMetadata.duration,
- mediaMetadata.album?.title,
- )
- } ?: continue
- val lyrics = result.getOrNull()
- if (lyrics != null) return LyricsWithProvider(lyrics, provider.id)
- result.exceptionOrNull()?.let(::reportException)
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- reportException(e)
+ fillWindow()
+ while (inFlight > 0) {
+ val timeout = deadline - System.nanoTime() / 1_000_000
+ if (timeout <= 0) break
+ val answer = withTimeoutOrNull(timeout) { answers.receive() } ?: break
+ val (index, result) = answer
+ inFlight--
+ completed += index
+ val provider = providers[index]
+ result.getOrNull()?.takeIf { it.isNotBlank() && it != LYRICS_NOT_FOUND && LyricsProviderSettings.isEnabled(context, provider) }
+ ?.let { policy.consider(provider.id, index, it) }
+ ?.let { onUpgrade(LyricsWithProvider(it.lyrics, it.provider)) }
+ val best = policy.best
+ if (best?.sync == LyricsSyncLevel.WORD && (0 until best.priority).all { it in completed }) break
+ fillWindow()
+ }
+ policy.best?.let { LyricsWithProvider(it.lyrics, it.provider) }
+ ?: LyricsWithProvider(LYRICS_NOT_FOUND, "Unknown")
+ } finally {
+ jobs.forEach { it.cancel() }
+ answers.cancel()
}
}
-
- return LyricsWithProvider(LYRICS_NOT_FOUND, "Unknown")
}
suspend fun getAllLyrics(
diff --git a/app/src/main/kotlin/com/convx/music/lyrics/LyricsProviderRepository.kt b/app/src/main/kotlin/com/convx/music/lyrics/LyricsProviderRepository.kt
index 9056b56d..99b3e5c6 100644
--- a/app/src/main/kotlin/com/convx/music/lyrics/LyricsProviderRepository.kt
+++ b/app/src/main/kotlin/com/convx/music/lyrics/LyricsProviderRepository.kt
@@ -4,6 +4,7 @@ import android.content.Context
import com.convx.music.db.entities.LyricsEntity
import com.convx.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND
import com.convx.music.models.MediaMetadata
+import com.convx.music.utils.dataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeout
@@ -33,6 +35,7 @@ class LyricsProviderRepository @Inject constructor(@ApplicationContext private v
private class Attempt(val work: Deferred>) {
var status = LyricsProviderStatus.FETCHING
var completedAt = 0L
+ var syncLevel = LyricsSyncLevel.PLAIN
}
private val lock = Any()
@@ -43,13 +46,13 @@ class LyricsProviderRepository @Inject constructor(@ApplicationContext private v
private fun MediaMetadata.key() = SongKey(id, title, artists.joinToString { it.name }, duration, album?.title)
- fun statuses(song: MediaMetadata): Flow