- 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:
2024-01-19 05:56:55 +02:00
parent a3980c803c
commit a0484e9ed9
14 changed files with 151 additions and 104 deletions
+12
View File
@@ -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
View File
@@ -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")
+3 -1
View File
@@ -18,4 +18,6 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-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,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 <i> 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 <i> 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 {
@@ -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,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,
),
)
}
}
}
}
@@ -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
}
}
@@ -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
}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>