diff --git a/README.md b/README.md index 8ac3a02..4ef945e 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,17 @@ App that listens to your notifications and provides lyrics for the currently pla ## Supported players Any player that uses the MediaSession API _should_ be supported +## Using Spotify for lyrics +If you plan on using the "Use Spotify for lyrics" feature you will need to create a Spotify developer app. +You can do so [here](https://developer.spotify.com/dashboard/applications), and you should follow these steps: +- Click on "Create app" in your developer dashboard +- Name your app whatever you'd like and give it a description +- Add the following redirect URI: `lyricsgrabbr://spotify-auth` +- Select `Android` under "Which API/SDKs are you planning to use?" +- Save your newly created app +- You will be redirected to your app page where you can click on "Settings" and find your Client ID under the "Basic Information" tab + +You can then use the Client ID of your app inside LyricsGrabbr to authenticate with Spotify. + ## License The project is licensed under the GPL3 license while the assets (icon) are licensed under the creative commons license. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8555a9b..05c2f44 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ import java.io.FileInputStream import java.util.Properties +import com.android.build.api.variant.FilterConfiguration.FilterType.ABI plugins { id("com.android.application") @@ -21,8 +22,8 @@ android { applicationId = "com.illusionman1212.lyricsgrabbr" minSdk = 23 targetSdk = 34 - versionCode = 5 - versionName = "0.4.0" + versionCode = 6 + versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -64,11 +65,26 @@ android { composeOptions { kotlinCompilerExtensionVersion = "1.5.1" } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - excludes += "/META-INF/DEPENDENCIES" - excludes += "mozilla/*" + + androidComponents { + val abiVersionCodes = mapOf( + "x86_64" to 1, + "armeabi-v7a" to 2, + "arm64-v8a" to 3, + ) + + onVariants { variant -> + // Assigns a different version code for each output APK + // other than the universal APK. + variant.outputs.forEach { output -> + val name = output.filters.find { it.filterType == ABI }?.identifier + + // Stores the value of abiCodes that is associated with the ABI for this variant. + val abiCode = abiVersionCodes[name] ?: 0 + // Assigns the new version code to output.versionCode, which changes the version code + // for only the output APK, not for the variant itself. + output.versionCode.set((output.versionCode.get() ?: 0) * 10 + abiCode) + } } } } @@ -93,7 +109,6 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") implementation("io.coil-kt:coil:2.5.0") implementation("io.coil-kt:coil-compose:2.5.0") - implementation("it.skrape:skrapeit:1.2.2") implementation("org.jsoup:jsoup:1.17.2") implementation("com.adamratzman:spotify-api-kotlin-core:4.0.2") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..35463f0 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,6 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +-dontwarn org.slf4j.impl.StaticLoggerBinder \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/LGApp.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/LGApp.kt index 5b2564d..f63dfcd 100644 --- a/app/src/main/java/com/illusionman1212/lyricsgrabbr/LGApp.kt +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/LGApp.kt @@ -23,7 +23,7 @@ class LGApp: Application() { lateinit var lyricsRepository: LyricsRepository val credentialStore by lazy { SpotifyDefaultCredentialStore( - clientId = "10d81ce453e743559ea362df2e6a1fd2", + clientId = "", redirectUri = SPOTIFY_REDIRECT_URI, applicationContext = applicationContext ) diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/MainActivity.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/MainActivity.kt index 1e880a3..9a842dd 100644 --- a/app/src/main/java/com/illusionman1212/lyricsgrabbr/MainActivity.kt +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/MainActivity.kt @@ -123,7 +123,6 @@ class MainActivity : ComponentActivity() { navController.navigate(Routes.Lyrics.route) }, lyricsViewModel = lyricsViewModel, - settingsViewModel = settingsViewModel, goBack = { finish() }, lastNotification = lastNotification, ) diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/LyricsRepository.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/LyricsRepository.kt index 820cd21..e60b454 100644 --- a/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/LyricsRepository.kt +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/LyricsRepository.kt @@ -2,14 +2,10 @@ package com.illusionman1212.lyricsgrabbr.data import android.content.Context import android.net.Uri -import android.util.Log import com.illusionman1212.lyricsgrabbr.R import com.illusionman1212.lyricsgrabbr.data.network.GET import com.illusionman1212.lyricsgrabbr.data.network.NetworkHelper import com.illusionman1212.lyricsgrabbr.utils.await -import it.skrape.core.htmlDocument -import it.skrape.selects.ElementNotFoundException -import it.skrape.selects.html5.div import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.jsoup.Jsoup @@ -56,8 +52,10 @@ class LyricsRepository(private val context: Context) { GET(url.toString()) ).await() + val body = res.body?.string() + if (res.isSuccessful) { - return Pair(json.decodeFromString(SuccessResponse.serializer(), res.body!!.string()) + return Pair(json.decodeFromString(SuccessResponse.serializer(), body!!) .lyrics .lines.joinToString(separator = "\n") { it.words }, true) } else { @@ -70,61 +68,41 @@ class LyricsRepository(private val context: Context) { } fun fetchLyricsFromGenius(url: Uri): String { - // TODO: write a custom fetcher for skrape.it to use our OkHttp client - // and get rid of jsoup. Or better yet, maybe skrape.it will be fixed in the future - // and we can just use its built in BrowserFetcher val doc = Jsoup.connect(url.toString()).userAgent(USER_AGENT).get() + val divs = doc.select("div[data-lyrics-container]") - try { - val lyrics = htmlDocument(doc.toString()) { - // set relaxed for instrumental songs - relaxed = true + val lyrics = StringUtil.borrowBuilder() - findFirst { - div { - withAttributeKey = "data-lyrics-container" - findAll { - this - }.joinToString(separator = "\n") { - val accum = StringUtil.borrowBuilder() - it.element.text() - - it.element.traverse(object : NodeVisitor { - override fun head(node: Node, depth: Int) { - if (node is TextNode) { - accum.append(node.text().trim()) - // append space here to get proper spacing when there's a tag or other inline text tags - accum.append(" ") - } else if (node is Element) { - if (accum.isNotEmpty() && node.normalName() == "br") { - accum.append("\n") - } - } - } - - override fun tail(node: Node, depth: Int) { - // since we append a space after every text node, we only append a newline if - // the last char isn't a space - val lastCharIsWhitespace = accum.isNotEmpty() && accum[accum.length - 1] == ' ' - if (node is Element) { - if (node.isBlock && node.nextSibling() is TextNode && !lastCharIsWhitespace) { - accum.append("\n") - } - } - } - }) - - accum + divs.forEach { + it.traverse(object : NodeVisitor { + override fun head(node: Node, depth: Int) { + if (node is TextNode) { + lyrics.append(node.text().trim()) + // append space here to get proper spacing when there's a tag or other inline text tags + lyrics.append(" ") + } else if (node is Element) { + if (lyrics.isNotEmpty() && node.normalName() == "br") { + lyrics.append("\n") } } - }.replace("\n\n\n", "\n\n") - } + } - return lyrics - } catch (e: ElementNotFoundException) { - // TODO: relaxed is broken in skrape.it, so we have to catch this exception - return "" + override fun tail(node: Node, depth: Int) { + // since we append a space after every text node, we only append a newline if + // the last char isn't a space + val lastCharIsWhitespace = lyrics.isNotEmpty() && lyrics[lyrics.length - 1] == ' ' + if (node is Element) { + if (node.isBlock && node.nextSibling() is TextNode && !lastCharIsWhitespace) { + lyrics.append("\n") + } + } + } + }) + + lyrics.append("\n") } + + return lyrics.toString().replace("\n\n\n", "\n\n") } companion object { diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGIconButton.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGIconButton.kt index 6e6c38e..2116a96 100644 --- a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGIconButton.kt +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGIconButton.kt @@ -2,6 +2,7 @@ package com.illusionman1212.lyricsgrabbr.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.RowScope import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.PlainTooltipBox @@ -9,7 +10,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.ModifierInfo import androidx.compose.ui.layout.layoutId +import com.illusionman1212.lyricsgrabbr.utils.mirror @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -29,9 +32,8 @@ fun LGIconButton( IconButton( onClick = onClick, Modifier.tooltipAnchor(), - ) { - content() - } + content = content + ) } Box(Modifier.layoutId("badge")) { badge?.let { it() } diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Home.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Home.kt index 1127aa2..302d4fd 100644 --- a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Home.kt +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Home.kt @@ -48,6 +48,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -102,7 +103,6 @@ fun HomePage( homeViewModel: HomeViewModel = viewModel( factory = HomeViewModel.Factory, ), - settingsViewModel: SettingsViewModel, lyricsViewModel: LyricsViewModel, lastNotification: MutableStateFlow ) { @@ -141,7 +141,7 @@ fun HomePage( val song = lastNotification.collectAsStateWithLifecycle().value val uiState = homeViewModel.uiState.collectAsStateWithLifecycle().value - var getLyricsFromSpotify by remember { mutableStateOf(false)} + var getLyricsFromSpotify by rememberSaveable { mutableStateOf(false)} LaunchedEffect(Unit) { if (!shouldShowNotificationPermissionRationale) diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Lyrics.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Lyrics.kt index 9295e24..d69b821 100644 --- a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Lyrics.kt +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Lyrics.kt @@ -22,21 +22,25 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.illusionman1212.lyricsgrabbr.R import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton +import com.illusionman1212.lyricsgrabbr.utils.mirror import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -82,6 +86,7 @@ fun LyricsPage( onClick = goBack ) { Icon( + modifier = Modifier.mirror(), imageVector = Icons.Filled.ArrowBack, contentDescription = stringResource( id = R.string.go_back @@ -107,23 +112,39 @@ fun LyricsPage( if (uiState.isLoading) { CircularProgressIndicator() } else { - if (uiState.lyrics.isEmpty()) { + if (uiState.error != null) { + Text( + uiState.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 32.dp) + ) + } else if (uiState.lyrics.isEmpty()) { Instrumental() } else { - Column(Modifier.padding(top = 12.dp, start = 12.dp, end = 12.dp, bottom = 32.dp)) { - SelectionContainer { - TextField( - value = uiState.lyrics, - onValueChange = {}, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - readOnly = true, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), + + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Column( + Modifier.padding( + top = 12.dp, + start = 12.dp, + end = 12.dp, + bottom = 32.dp ) + ) { + SelectionContainer { + TextField( + value = uiState.lyrics, + onValueChange = {}, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + readOnly = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } } } } diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Settings.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Settings.kt index d42e40a..10a1620 100644 --- a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Settings.kt +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Settings.kt @@ -27,7 +27,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,15 +37,15 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -62,6 +61,7 @@ import com.illusionman1212.lyricsgrabbr.ui.theme.ColorSecondaryLight import com.illusionman1212.lyricsgrabbr.data.Theme import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton import com.illusionman1212.lyricsgrabbr.utils.annotatedStringResource +import com.illusionman1212.lyricsgrabbr.utils.mirror import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsState import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel import kotlinx.coroutines.runBlocking @@ -131,25 +131,23 @@ fun SettingsPage( Scaffold( topBar = { - // TODO: on RTL languages all newer(only?) android systems change the direction of the status bar - // including all its children, so we need to figure out how to position stuff to the right - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .statusBarsPadding() - .padding(vertical = 8.dp, horizontal = 4.dp), - ) { - LGIconButton(tooltip = stringResource(id = R.string.go_back), onClick = goBack) { - Icon( - imageVector = Icons.Filled.ArrowBack, - contentDescription = stringResource( + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .statusBarsPadding() + .padding(vertical = 8.dp, horizontal = 4.dp) + .fillMaxWidth(), + ) { + LGIconButton(tooltip = stringResource(id = R.string.go_back), onClick = goBack) { + Icon( + modifier = Modifier.mirror(), + imageVector = Icons.Filled.ArrowBack, + contentDescription = stringResource( id = R.string.go_back ) ) - } - Text(text = stringResource(id = R.string.settings), style = MaterialTheme.typography.titleLarge) } + Text(text = stringResource(id = R.string.settings), style = MaterialTheme.typography.titleLarge) } }, ) { padding -> @@ -278,6 +276,7 @@ fun Lyrics(viewModel: SettingsViewModel, uiState: SettingsState) { val spotifyDesc = annotatedStringResource(R.string.spotify_client_id_desc) { when (it.key) { "href" -> SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline) + "weight" -> SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic) else -> null } } diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Theme.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Theme.kt index 3efdedd..e3b0287 100644 --- a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Theme.kt +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Theme.kt @@ -115,6 +115,9 @@ fun LyricsGrabbrTheme( } } + // BUG: color is not being set on older android version (Marshmallow and others). + // The navigation bar color causes the nav buttons to be very hard to see + // in light mode because the buttons are white. DisposableEffect(darkTheme) { (context as ComponentActivity).enableEdgeToEdge( statusBarStyle = SystemBarStyle.auto( diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/utils/Modifiers.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/utils/Modifiers.kt new file mode 100644 index 0000000..c6d768b --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/utils/Modifiers.kt @@ -0,0 +1,16 @@ +package com.illusionman1212.lyricsgrabbr.utils + +import android.util.LayoutDirection +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.core.text.layoutDirection +import java.util.Locale + +@Stable +fun Modifier.mirror(): Modifier { + return if (Locale.getDefault().layoutDirection == LayoutDirection.RTL) + this.scale(scaleX = -1f, scaleY = 1f) + else + this +} \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index b8af128..226cb4a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -37,7 +37,7 @@ استخدم Spotify للكلمات Spotify Client ID خالي - يمكن لهذا التطبيق استخدام واجهة برمجة تطبيقات الويب الخاصة بـ Spotify للحصول على كلمات الأغاني. تحتاج إلى تقديم Spotify Client ID لاستخدام هذه الميزة. يمكنك الحصول على واحدة من هنا.\nلا يعمل هذا إلا إذا كنت تقوم بتشغيل الموسيقى من مشغل Spotify. + يمكن لهذا التطبيق استخدام واجهة برمجة تطبيقات الويب الخاصة بـ Spotify للحصول على كلمات الأغاني. تحتاج إلى تقديم Spotify Client ID لاستخدام هذه الميزة. يمكنك الحصول على واحدة من هنا.\nلا يعمل هذا إلا إذا كنت تقوم بتشغيل الموسيقى من مشغل Spotify. كلمات الأغنية diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0e1a50..9935d3c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,7 +36,7 @@ Use Spotify for lyrics Spotify Client ID Not Set - This app can use Spotify\'s Web API to get lyrics. You need to provide a Spotify Client ID to use this feature. You can get one from here.\nThis only works if you\'re playing music from the Spotify player. + This app can use Spotify\'s Web API to get lyrics. You need to provide a Spotify Client ID to use this feature. You can get one from here.\nThis only works if you\'re playing music from the Spotify player. Lyrics