mirror of
https://github.com/IllusionMan1212/lyrics-grabbr.git
synced 2026-10-24 02:12:27 +00:00
fixes
- added instructions on how to use the "Spotify for lyrics" feature in the README - added ABI splits in preparation for the f-droid relase - removed the skrape.it dependency - fixed bug where the "This song is an instrumental" screen always shows after the first time of fetching lyrics from spotify - properly position title and rotate navigation button icon on topbar for RTL languages - show errors on the lyrics screen - version 1.0.0
This commit is contained in:
@@ -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.
|
||||
|
||||
+23
-8
@@ -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")
|
||||
|
||||
|
||||
Vendored
+2
@@ -19,3 +19,5 @@
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -123,7 +123,6 @@ class MainActivity : ComponentActivity() {
|
||||
navController.navigate(Routes.Lyrics.route)
|
||||
},
|
||||
lyricsViewModel = lyricsViewModel,
|
||||
settingsViewModel = settingsViewModel,
|
||||
goBack = { finish() },
|
||||
lastNotification = lastNotification,
|
||||
)
|
||||
|
||||
@@ -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,34 +68,21 @@ 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 {
|
||||
divs.forEach {
|
||||
it.traverse(object : NodeVisitor {
|
||||
override fun head(node: Node, depth: Int) {
|
||||
if (node is TextNode) {
|
||||
accum.append(node.text().trim())
|
||||
lyrics.append(node.text().trim())
|
||||
// append space here to get proper spacing when there's a <i> tag or other inline text tags
|
||||
accum.append(" ")
|
||||
lyrics.append(" ")
|
||||
} else if (node is Element) {
|
||||
if (accum.isNotEmpty() && node.normalName() == "br") {
|
||||
accum.append("\n")
|
||||
if (lyrics.isNotEmpty() && node.normalName() == "br") {
|
||||
lyrics.append("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,26 +90,19 @@ class LyricsRepository(private val context: Context) {
|
||||
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] == ' '
|
||||
val lastCharIsWhitespace = lyrics.isNotEmpty() && lyrics[lyrics.length - 1] == ' '
|
||||
if (node is Element) {
|
||||
if (node.isBlock && node.nextSibling() is TextNode && !lastCharIsWhitespace) {
|
||||
accum.append("\n")
|
||||
lyrics.append("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
accum
|
||||
}
|
||||
}
|
||||
}.replace("\n\n\n", "\n\n")
|
||||
lyrics.append("\n")
|
||||
}
|
||||
|
||||
return lyrics
|
||||
} catch (e: ElementNotFoundException) {
|
||||
// TODO: relaxed is broken in skrape.it, so we have to catch this exception
|
||||
return ""
|
||||
}
|
||||
return lyrics.toString().replace("\n\n\n", "\n\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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<NotificationEvent?>
|
||||
) {
|
||||
@@ -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)
|
||||
|
||||
@@ -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,10 +112,25 @@ 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)) {
|
||||
|
||||
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,
|
||||
@@ -130,6 +150,7 @@ fun LyricsPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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,18 +131,17 @@ 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),
|
||||
.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
|
||||
@@ -150,7 +149,6 @@ fun SettingsPage(
|
||||
}
|
||||
Text(text = stringResource(id = R.string.settings), style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
<string name="use_spotify_api">استخدم Spotify للكلمات</string>
|
||||
<string name="spotify_client_id">Spotify Client ID</string>
|
||||
<string name="not_set">خالي</string>
|
||||
<string name="spotify_client_id_desc">يمكن لهذا التطبيق استخدام واجهة برمجة تطبيقات الويب الخاصة بـ Spotify للحصول على كلمات الأغاني. تحتاج إلى تقديم Spotify Client ID لاستخدام هذه الميزة. يمكنك الحصول على واحدة من <annotation href="https://developer.spotify.com/dashboard/applications">هنا</annotation>.\nلا يعمل هذا إلا إذا كنت تقوم بتشغيل الموسيقى من مشغل Spotify.</string>
|
||||
<string name="spotify_client_id_desc">يمكن لهذا التطبيق استخدام واجهة برمجة تطبيقات الويب الخاصة بـ Spotify للحصول على كلمات الأغاني. تحتاج إلى تقديم Spotify Client ID لاستخدام هذه الميزة. يمكنك الحصول على واحدة من <annotation href="https://developer.spotify.com/dashboard/applications">هنا</annotation>.\n<annotation weight="bold">لا يعمل هذا إلا إذا كنت تقوم بتشغيل الموسيقى من مشغل Spotify.</annotation></string>
|
||||
|
||||
<!-- Lyrics -->
|
||||
<string name="lyrics">كلمات الأغنية</string>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<string name="use_spotify_api">Use Spotify for lyrics</string>
|
||||
<string name="spotify_client_id" translatable="false">Spotify Client ID</string>
|
||||
<string name="not_set">Not Set</string>
|
||||
<string name="spotify_client_id_desc">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 <annotation href="https://developer.spotify.com/dashboard/applications">here</annotation>.\nThis only works if you\'re playing music from the Spotify player.</string>
|
||||
<string name="spotify_client_id_desc">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 <annotation href="https://developer.spotify.com/dashboard/applications">here</annotation>.\n<annotation weight="bold">This only works if you\'re playing music from the Spotify player.</annotation></string>
|
||||
|
||||
<!-- Lyrics -->
|
||||
<string name="lyrics">Lyrics</string>
|
||||
|
||||
Reference in New Issue
Block a user