rewrite the app in native kotlin with compose

This commit is contained in:
2024-01-19 20:47:36 +02:00
committed by Illusion
parent a40cf85925
commit 0069f62115
92 changed files with 3391 additions and 2008 deletions
@@ -0,0 +1,69 @@
package com.illusionman1212.lyricsgrabbr.data
import android.content.Context
import android.net.Uri
import androidx.core.app.NotificationManagerCompat
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 kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okio.IOException
import java.net.SocketTimeoutException
@Serializable
data class ErrorResponse(
val error: Boolean,
val matches: Int?,
)
class HomeRepository(private val context: Context) {
private val networkHelper = NetworkHelper(context)
private val json = Json {
ignoreUnknownKeys = true
}
fun isPermissionGranted(): Boolean {
val packageName = context.packageName
val pkgs = NotificationManagerCompat.getEnabledListenerPackages(context)
return pkgs.contains(packageName)
}
suspend fun makeSearchRequest(song: String, artist: String): Pair<String?, Boolean> {
val builder = Uri.Builder()
val url = builder.scheme("https")
.authority("api.illusionman1212.com")
.appendPath("lyrics")
.appendPath("search")
.appendQueryParameter("q", "$song $artist")
.appendQueryParameter("disableFuzzy", "true")
.build()
try {
val res = networkHelper.client.newCall(
GET(url.toString())
).await()
if (!res.isSuccessful) {
val errRes = json.decodeFromString(ErrorResponse.serializer(), res.body!!.string())
// matches is only null if there's an error. otherwise it's 0 when there are no results
if (errRes.matches == null) {
return Pair(context.resources.getString(R.string.generic_error), false)
}
return Pair(null, false)
}
return Pair(res.body?.string(), true)
} catch (e: IOException) {
if (e.cause is SocketTimeoutException) {
return Pair(context.resources.getString(R.string.timeout), false)
}
return Pair(context.resources.getString(R.string.generic_error), false)
}
}
}
@@ -0,0 +1,133 @@
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
import org.jsoup.internal.StringUtil
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import org.jsoup.select.NodeVisitor
@Serializable
data class LyricLine(
val startTimeMs: ULong,
val words: String,
)
@Serializable
data class Lyrics(
val syncType: String,
val lines: List<LyricLine>,
val language: String,
)
@Serializable
data class SuccessResponse(
val lyrics: Lyrics,
)
class LyricsRepository(private val context: Context) {
private val networkHelper = NetworkHelper(context)
private val json = Json {
ignoreUnknownKeys = true
}
suspend fun fetchLyricsFromSpotify(trackId: String): Pair<String, Boolean> {
val builder = Uri.Builder()
val url = builder.scheme("https")
.authority("api.lyricstify.vercel.app")
.appendPath("v1")
.appendPath("lyrics")
.appendPath(trackId)
.build()
val res = networkHelper.client.newCall(
GET(url.toString())
).await()
if (res.isSuccessful) {
return Pair(json.decodeFromString(SuccessResponse.serializer(), res.body!!.string())
.lyrics
.lines.joinToString(separator = "\n") { it.words }, true)
} else {
if (res.code == 404) {
return Pair(context.resources.getString(R.string.spotify_no_lyrics_error), false)
}
return Pair(context.resources.getString(R.string.generic_error), false)
}
}
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()
try {
val lyrics = htmlDocument(doc.toString()) {
// set relaxed for instrumental songs
relaxed = true
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
}
}
}.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 ""
}
}
companion object {
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.3"
}
}
@@ -0,0 +1,39 @@
package com.illusionman1212.lyricsgrabbr.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import kotlinx.coroutines.flow.map
enum class Theme {
LIGHT,
DARK,
SYSTEM,
}
data class SettingsPreferences(
val appTheme: Int = Theme.SYSTEM.ordinal,
)
class SettingsPreferencesRepository(
private val dataStore: DataStore<Preferences>
) {
companion object {
private val APP_THEME = intPreferencesKey("app_theme")
}
val preferences = dataStore.data.map { mapSettingsPreferences(it) }
suspend fun setTheme(theme: Theme) {
dataStore.edit { prefs ->
prefs[APP_THEME] = theme.ordinal
}
}
private fun mapSettingsPreferences(prefs: Preferences): SettingsPreferences {
return SettingsPreferences(
prefs[APP_THEME] ?: Theme.SYSTEM.ordinal,
)
}
}
@@ -0,0 +1,24 @@
package com.illusionman1212.lyricsgrabbr.data.network
import android.content.Context
import okhttp3.Cache
import okhttp3.OkHttpClient
import java.io.File
import java.util.concurrent.TimeUnit
class NetworkHelper(context: Context) {
private val cacheDir = File(context.cacheDir, "network_cache")
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
private val baseClientBuilder: OkHttpClient.Builder
get() {
val builder = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.callTimeout(2, TimeUnit.MINUTES)
return builder
}
val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
}
@@ -0,0 +1,31 @@
package com.illusionman1212.lyricsgrabbr.data.network
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import java.util.concurrent.TimeUnit
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, TimeUnit.MINUTES).build()
private val DEFAULT_HEADERS = Headers.Builder().build()
fun GET(
url: String,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return GET(url.toHttpUrl(), headers, cache)
}
fun GET(
url: HttpUrl,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
.headers(headers)
.cacheControl(cache)
.build()
}