- 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 20:47:36 +02:00
committed by Illusion
parent 92c610a761
commit f5afb3b25c
14 changed files with 151 additions and 104 deletions
@@ -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
}