rewrite the app in native kotlin with compose

This commit is contained in:
2024-01-18 03:17:01 +02:00
parent a40cf85925
commit 6f3555b1d3
92 changed files with 3391 additions and 2008 deletions
@@ -0,0 +1,38 @@
package com.illusionman1212.lyricsgrabbr
import android.app.Application
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.adamratzman.spotify.auth.SpotifyDefaultCredentialStore
import com.illusionman1212.lyricsgrabbr.data.HomeRepository
import com.illusionman1212.lyricsgrabbr.data.LyricsRepository
import com.illusionman1212.lyricsgrabbr.data.SettingsPreferencesRepository
private const val PREFERENCES_NAME = "preferences"
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = PREFERENCES_NAME
)
const val SPOTIFY_REDIRECT_URI = "lyricsgrabbr://spotify-auth"
class LGApp: Application() {
lateinit var settingsPreferencesRepository: SettingsPreferencesRepository
lateinit var homeRepository: HomeRepository
lateinit var lyricsRepository: LyricsRepository
val credentialStore by lazy {
SpotifyDefaultCredentialStore(
clientId = "10d81ce453e743559ea362df2e6a1fd2",
redirectUri = SPOTIFY_REDIRECT_URI,
applicationContext = applicationContext
)
}
override fun onCreate() {
super.onCreate()
settingsPreferencesRepository = SettingsPreferencesRepository(dataStore)
homeRepository = HomeRepository(applicationContext)
lyricsRepository = LyricsRepository(applicationContext)
}
}
@@ -0,0 +1,198 @@
package com.illusionman1212.lyricsgrabbr
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.illusionman1212.lyricsgrabbr.data.Theme
import com.illusionman1212.lyricsgrabbr.ui.screens.HomePage
import com.illusionman1212.lyricsgrabbr.ui.screens.LyricsPage
import com.illusionman1212.lyricsgrabbr.ui.theme.LyricsGrabbrTheme
import com.illusionman1212.lyricsgrabbr.ui.screens.SettingsPage
import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking
data class NotificationEvent(
val packageName: String,
val artist: String,
val title: String,
val playbackState: Int,
val duration: Long
)
class MainActivity : ComponentActivity() {
var lastNotification: MutableStateFlow<NotificationEvent?> = MutableStateFlow(null)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val context = LocalContext.current
val activity = context as Activity
val application = activity.application as LGApp
val settingsViewModel: SettingsViewModel = viewModel(
factory = SettingsViewModel.Factory
)
val lyricsViewModel: LyricsViewModel = viewModel(
factory = LyricsViewModel.Factory
)
val appTheme = remember { mutableIntStateOf(Theme.SYSTEM.ordinal) }
val navController = rememberNavController()
LaunchedEffect(settingsViewModel.uiState) {
settingsViewModel.uiState.collect { newState ->
if (appTheme.intValue != newState.appTheme) {
appTheme.intValue = newState.appTheme
}
}
}
runBlocking {
settingsViewModel.updateSpotifyAuthStatus(application.credentialStore.spotifyToken?.refreshToken != null)
appTheme.intValue = settingsViewModel.getAppTheme()
}
val darkTheme = when (appTheme.intValue) {
Theme.LIGHT.ordinal -> false
Theme.DARK.ordinal -> true
else -> isSystemInDarkTheme()
}
LyricsGrabbrTheme(darkTheme = darkTheme) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
NavHost(
navController = navController,
startDestination = Routes.Home.route,
enterTransition = {
slideInHorizontally(
initialOffsetX = { 1000 },
animationSpec = tween(300)
) + fadeIn(animationSpec = tween(300))
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -1000 },
animationSpec = tween(300)
) + fadeOut(animationSpec = tween(300))
},
) {
composable(Routes.Home.route) {
HomePage(
navigateToSettings = {
navController.navigate(Routes.Settings.route)
},
navigateToLyrics = {
navController.navigate(Routes.Lyrics.route)
},
lyricsViewModel = lyricsViewModel,
settingsViewModel = settingsViewModel,
goBack = { finish() },
lastNotification = lastNotification,
)
}
composable(Routes.Lyrics.route) {
LyricsPage(
goBack = {
navController.popBackStack()
},
lyricsViewModel = lyricsViewModel,
)
}
composable(Routes.Settings.route) {
SettingsPage(
goBack = {
navController.popBackStack()
},
viewModel = settingsViewModel,
)
}
}
}
}
}
}
override fun onResume() {
super.onResume()
startListening()
}
private fun isPermissionGranted(): Boolean {
val packageName = packageName
val pkgs = NotificationManagerCompat.getEnabledListenerPackages(applicationContext)
return pkgs.contains(packageName)
}
private fun startListening() {
val isServiceRunning = NotificationListener.isRunning.value
if (!isPermissionGranted() || isServiceRunning) {
return
}
val listenerIntent = Intent(applicationContext, NotificationListener::class.java)
listenerIntent.action = NotificationListener.START_FOREGROUND_SERVICE_ACTION
val filter = IntentFilter()
filter.addAction(NotificationListener.NOTIFICATION_INTENT)
LocalBroadcastManager.getInstance(applicationContext).registerReceiver(NotificationReceiver(), filter)
ContextCompat.startForegroundService(applicationContext, listenerIntent)
}
inner class NotificationReceiver: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val artist = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_ARTIST)
val songTitle = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_SONG_TITLE)
val playbackState = intent.getIntExtra(NotificationListener.NOTIFICATION_PACKAGE_PLAYBACK_STATE, -1)
val duration = intent.getLongExtra(NotificationListener.NOTIFICATION_PACKAGE_DURATION, -1)
val notifPackageName = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_NAME)
lastNotification.update { NotificationEvent(
title = songTitle!!,
artist = artist!!,
playbackState = playbackState,
duration = duration,
packageName = notifPackageName!!,
) }
}
}
}
@@ -0,0 +1,183 @@
package com.illusionman1212.lyricsgrabbr
import android.Manifest.permission.POST_NOTIFICATIONS
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.os.Build
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class NotificationListener: NotificationListenerService() {
private var notificationManager : NotificationManagerCompat? = null
override fun onCreate() {
super.onCreate()
initNotificationChannels(this)
notificationManager = NotificationManagerCompat.from(applicationContext)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null) {
when (intent.action) {
START_FOREGROUND_SERVICE_ACTION -> {
startForegroundService()
getCurrentNotifications(applicationContext)
}
}
}
return super.onStartCommand(intent, flags, startId)
}
override fun onListenerConnected() {
getCurrentNotifications(applicationContext)
}
override fun onListenerDisconnected() {
stopForegroundService()
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
sendNotificationBroadcast(sbn, applicationContext)
}
private fun getStopServiceNotificationAction(): PendingIntent {
val intent = Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
return pendingIntent
}
private fun startForegroundService() {
_isRunning.update { true }
startForeground(NOTIFICATION_ID, getInitialNotification())
}
private fun stopForegroundService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
stopForeground(true)
}
stopSelf()
}
private fun initNotificationChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val notificationManager =
context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Lyrics",
NotificationManager.IMPORTANCE_LOW
)
notificationManager.createNotificationChannel(channel)
}
private fun getCurrentNotifications(context: Context) {
val sbns = activeNotifications
for (sbn in sbns) {
sendNotificationBroadcast(sbn, context)
}
}
private fun getInitialNotification(): Notification {
val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle(resources.getString(R.string.service_running))
.setContentText(resources.getString(R.string.service_notif_desc_init))
.setSmallIcon(R.drawable.ic_notification)
.setShowWhen(false)
.addAction(R.drawable.ic_notification, resources.getString(R.string.stop_service), getStopServiceNotificationAction())
.build()
notification.flags = Notification.FLAG_FOREGROUND_SERVICE
return notification
}
private fun sendNotificationBroadcast(sbn: StatusBarNotification, context: Context) {
if (ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED) {
return
}
// Apparently the new getParcelable API is buggy and throws an exception if used.
// so we continue to use the deprecated API even on newer versions.
// https://issuetracker.google.com/issues/240585930
val mediaSessionToken = sbn.notification.extras.getParcelable<MediaSession.Token>(Notification.EXTRA_MEDIA_SESSION)
?: return
val mediaCtrl = MediaController(context, mediaSessionToken)
val duration = mediaCtrl.metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.div(1000)
val playbackState = mediaCtrl.playbackState?.state
val albumArt = mediaCtrl.metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
// Retrieve extra object from notification to extract payload.
val extras = sbn.notification.extras
val packageArtist = extras?.getCharSequence(Notification.EXTRA_TEXT).toString()
val packageSongTitle = extras?.getCharSequence("android.title").toString()
// Pass data from one activity to another.
val intent = Intent(NOTIFICATION_INTENT)
intent.putExtra(NOTIFICATION_PACKAGE_NAME, sbn.packageName)
intent.putExtra(NOTIFICATION_PACKAGE_ARTIST, packageArtist)
intent.putExtra(NOTIFICATION_PACKAGE_SONG_TITLE, packageSongTitle)
intent.putExtra(NOTIFICATION_PACKAGE_DURATION, duration)
intent.putExtra(NOTIFICATION_PACKAGE_PLAYBACK_STATE, playbackState)
if (playbackState == PlaybackState.STATE_PAUSED) {
notificationManager?.notify(NOTIFICATION_ID, getInitialNotification())
} else {
val notificationIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle(resources.getString(R.string.now_playing, "[${packageSongTitle}]", "[${packageArtist}]"))
.setContentText(resources.getString(R.string.service_notif_desc_playing))
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(albumArt)
.setShowWhen(false)
.setContentIntent(pendingIntent)
.addAction(R.drawable.ic_notification, resources.getString(R.string.stop_service), getStopServiceNotificationAction())
.build()
notification.flags = Notification.FLAG_FOREGROUND_SERVICE
notificationManager?.notify(NOTIFICATION_ID, notification)
}
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
}
companion object {
private val _isRunning = MutableStateFlow(false)
val isRunning = _isRunning.asStateFlow()
const val NOTIFICATION_INTENT = "notification_event"
const val NOTIFICATION_PACKAGE_NAME = "package_name"
const val NOTIFICATION_PACKAGE_ARTIST = "package_artist"
const val NOTIFICATION_PACKAGE_SONG_TITLE = "package_song_title"
const val NOTIFICATION_PACKAGE_PLAYBACK_STATE = "package_playback_state"
const val NOTIFICATION_PACKAGE_DURATION = "package_duration"
const val NOTIFICATION_ID = 1
const val NOTIFICATION_CHANNEL_ID = "Lyrics"
const val START_FOREGROUND_SERVICE_ACTION = "START_SERVICE"
}
}
@@ -0,0 +1,8 @@
package com.illusionman1212.lyricsgrabbr
sealed class Routes(val route: String) {
object Home : Routes("home")
object Lyrics : Routes("lyrics")
object Settings : Routes("settings")
}
@@ -0,0 +1,29 @@
package com.illusionman1212.lyricsgrabbr
import android.widget.Toast
import com.adamratzman.spotify.SpotifyClientApi
import com.adamratzman.spotify.SpotifyScope
import com.adamratzman.spotify.auth.pkce.AbstractSpotifyPkceLoginActivity
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
class SpotifyPkceLoginActivityImpl: AbstractSpotifyPkceLoginActivity() {
override val clientId: String = CLIENT_ID
override val redirectUri: String = SPOTIFY_REDIRECT_URI
override val scopes: List<SpotifyScope> = listOf(SpotifyScope.UserReadCurrentlyPlaying)
override fun onFailure(exception: Exception) {
Toast.makeText(this, "Failed to authenticate", Toast.LENGTH_SHORT).show()
settingsViewModel?.updateSpotifyAuthStatus(false)
}
override fun onSuccess(api: SpotifyClientApi) {
(application as LGApp).credentialStore.setSpotifyApi(api)
Toast.makeText(this, "Successfully authenticated", Toast.LENGTH_SHORT).show()
settingsViewModel?.updateSpotifyAuthStatus(true)
}
companion object {
var CLIENT_ID = ""
var settingsViewModel: SettingsViewModel? = null
}
}
@@ -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()
}
@@ -0,0 +1,77 @@
package com.illusionman1212.lyricsgrabbr.ui.components
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.withSave
const val DefaultAlpha = 1f
fun forwardingPainter(
painter: Painter,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
size: Size,
onDraw: DrawScope.(ForwardingDrawInfo) -> Unit = DefaultOnDraw,
): Painter = ForwardingPainter(painter, alpha, colorFilter, size, onDraw)
data class ForwardingDrawInfo(
val painter: Painter,
val alpha: Float,
val colorFilter: ColorFilter?,
val size: Size,
)
private class ForwardingPainter(
private val painter: Painter,
private var alpha: Float,
private var colorFilter: ColorFilter?,
private val size: Size,
private val onDraw: DrawScope.(ForwardingDrawInfo) -> Unit,
) : Painter() {
private var info = newInfo()
override val intrinsicSize get() = painter.intrinsicSize
override fun applyAlpha(alpha: Float): Boolean {
if (alpha != DefaultAlpha) {
this.alpha = alpha
this.info = newInfo()
}
return true
}
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
if (colorFilter != null) {
this.colorFilter = colorFilter
this.info = newInfo()
}
return true
}
override fun DrawScope.onDraw() = onDraw(info)
private fun newInfo() = ForwardingDrawInfo(painter, alpha, colorFilter, size)
}
private val DefaultOnDraw: DrawScope.(ForwardingDrawInfo) -> Unit = { info ->
val size = info.size
drawIntoCanvas {
val width = it.nativeCanvas.width.toFloat()
val height = it.nativeCanvas.height.toFloat()
val left = (width - size.width) / 2f
val top = (height - size.height) / 2f
it.withSave {
it.translate(left, top)
with(info.painter) {
draw(size, info.alpha, info.colorFilter)
}
}
}
}
@@ -0,0 +1,53 @@
package com.illusionman1212.lyricsgrabbr.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.illusionman1212.lyricsgrabbr.ui.theme.Typography
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LGAlertDialog(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
properties: DialogProperties = DialogProperties(),
title: String,
buttons: @Composable () -> Unit,
content: @Composable () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
properties = properties,
) {
Surface (
modifier = Modifier.wrapContentHeight().wrapContentWidth(),
shape = MaterialTheme.shapes.large,
tonalElevation = AlertDialogDefaults.TonalElevation
) {
Column(
modifier = modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = title, style = Typography.titleLarge)
content()
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
buttons()
}
}
}
}
}
@@ -0,0 +1,74 @@
package com.illusionman1212.lyricsgrabbr.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.PlainTooltipBox
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.layoutId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LGIconButton(
tooltip: String,
onClick: () -> Unit,
badge: (@Composable BoxScope.() -> Unit)? = null,
content: @Composable () -> Unit
) {
Layout(
content = {
PlainTooltipBox(
tooltip = {
Text(text = tooltip)
},
) {
IconButton(
onClick = onClick,
Modifier.tooltipAnchor(),
) {
content()
}
}
Box(Modifier.layoutId("badge")) {
badge?.let { it() }
}
}
) { measurables, constraints ->
val iconPlaceable = measurables[0].measure(constraints)
val badgePlaceable = measurables.firstOrNull { it.layoutId == "badge" }?.measure(constraints)
layout(iconPlaceable.width, iconPlaceable.height) {
iconPlaceable.placeRelative(0, 0)
badgePlaceable?.placeRelative(
x = iconPlaceable.width - badgePlaceable.width - 16,
y = 16,
)
}
}
// PlainTooltipBox(tooltip = {
// Text(text = tooltip)
// }) {
// if (badge != null) {
// IconButton(
// onClick = onClick,
// Modifier.tooltipAnchor(),
// ) {
// BadgedBox(badge = badge) {
// content()
// }
// }
// } else {
// IconButton(
// onClick = onClick,
// Modifier.tooltipAnchor(),
// ) {
// content()
// }
// }
// }
}
@@ -0,0 +1,28 @@
package com.illusionman1212.lyricsgrabbr.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun LGRadioButton(text: String, onClick: () -> Unit, selected: Boolean) {
Box (Modifier.clickable { onClick() }.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = selected, onClick = onClick)
Text(text = text)
}
}
}
@Composable
@Preview(showBackground = true)
fun LGRadioButtonPreview() {
LGRadioButton(text = "Radio Button", onClick = {}, selected = true)
}
@@ -0,0 +1,552 @@
package com.illusionman1212.lyricsgrabbr.ui.screens
import android.Manifest.permission.POST_NOTIFICATIONS
import android.app.Activity
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler
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.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.core.app.ActivityCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.currentStateAsState
import androidx.lifecycle.viewmodel.compose.viewModel
import com.illusionman1212.lyricsgrabbr.NotificationEvent
import com.illusionman1212.lyricsgrabbr.R
import com.illusionman1212.lyricsgrabbr.ui.components.LGAlertDialog
import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton
import com.illusionman1212.lyricsgrabbr.viewmodels.HomeViewModel
import com.illusionman1212.lyricsgrabbr.viewmodels.SearchResult
import kotlinx.coroutines.flow.MutableStateFlow
import coil.compose.AsyncImage
import com.illusionman1212.lyricsgrabbr.LGApp
import com.illusionman1212.lyricsgrabbr.ui.components.forwardingPainter
import com.illusionman1212.lyricsgrabbr.utils.annotatedStringResource
import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomePage(
navigateToSettings: () -> Unit,
navigateToLyrics: () -> Unit,
goBack: () -> Unit,
homeViewModel: HomeViewModel = viewModel(
factory = HomeViewModel.Factory,
),
settingsViewModel: SettingsViewModel,
lyricsViewModel: LyricsViewModel,
lastNotification: MutableStateFlow<NotificationEvent?>
) {
val context = LocalContext.current
val application = (context as Activity).application as LGApp
val lifecycleOwner = LocalLifecycleOwner.current
val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState()
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
var artistUri by remember { mutableStateOf(Uri.EMPTY) }
var songUri by remember { mutableStateOf(Uri.EMPTY) }
var showBottomSheet by remember { mutableStateOf(false) }
var listeningPermission = homeViewModel.getListeningPermission()
var notificationPermission by remember { mutableStateOf(
ActivityCompat.checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED
) }
var shouldShowNotificationPermissionRationale by remember { mutableStateOf(
ActivityCompat.shouldShowRequestPermissionRationale(
context as android.app.Activity,
POST_NOTIFICATIONS
)
)}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
notificationPermission = isGranted
// we hide the rationale after launching once because launching again doesn't seem work
shouldShowNotificationPermissionRationale = false
}
val song = lastNotification.collectAsStateWithLifecycle().value
val uiState = homeViewModel.uiState.collectAsStateWithLifecycle().value
var getLyricsFromSpotify by remember { mutableStateOf(false)}
LaunchedEffect(Unit) {
if (!shouldShowNotificationPermissionRationale)
launcher.launch(POST_NOTIFICATIONS)
}
LaunchedEffect(lifecycleState) {
when (lifecycleState) {
Lifecycle.State.RESUMED -> {
listeningPermission = homeViewModel.getListeningPermission()
}
else -> {}
}
}
LaunchedEffect(song) {
if (song != null && song.title != uiState.notification?.title && song.artist != uiState.notification?.artist) {
val spotifyApi = application.credentialStore.getSpotifyClientPkceApi()
val useSpotifyApi = song.packageName == "com.spotify.music" && spotifyApi != null
getLyricsFromSpotify = useSpotifyApi
homeViewModel.setSong(song)
if (useSpotifyApi) {
homeViewModel.makeRequestToSpotify(spotifyApi!!)
} else {
homeViewModel.makeRequestToGenius(song.title, song.artist)
}
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text(text = stringResource(id = R.string.app_name)) },
actions = {
LGIconButton(
tooltip = stringResource(id = R.string.settings),
onClick = navigateToSettings,
) {
Icon(
Icons.Filled.Settings,
contentDescription = stringResource(id = R.string.settings)
)
}
}
)
},
) { padding ->
Column(
Modifier
.padding(padding)
.padding(top = 16.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (!notificationPermission && shouldShowNotificationPermissionRationale) {
LGAlertDialog(
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
),
onDismiss = goBack,
title = stringResource(id = R.string.notification_post_permission),
buttons = {
TextButton(
onClick = {
launcher.launch(POST_NOTIFICATIONS)
}
) {
Text(stringResource(id = R.string.grant_permission))
}
}
) {
Text(
text = stringResource(id = R.string.notification_post_permission_desc),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
if (!listeningPermission && notificationPermission) {
LGAlertDialog(
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
),
onDismiss = goBack,
title = stringResource(id = R.string.notification_listen_permission),
buttons = {
TextButton(
onClick = {
context.startActivity(
android.content.Intent(
android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS
)
)
}
) {
Text(stringResource(id = R.string.open_settings))
}
}
) {
Text(
text = stringResource(id = R.string.notification_listen_desc),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (song == null) {
Column(
Modifier.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text( text = stringResource(id = R.string.no_supported_player), style = MaterialTheme.typography.headlineSmall )
if (!notificationPermission) {
Text(
text = stringResource(id = R.string.notification_post_permission_not_granted).uppercase(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
}
Spacer(Modifier.weight(1f))
} else {
Column(
Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = song.title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
)
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = song.artist,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
}
Row(
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (uiState.isLoading) {
CircularProgressIndicator()
} else {
if (uiState.error != null) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = uiState.error,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
fontStyle = FontStyle.Italic,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.error
)
FilledTonalButton(
onClick = {
homeViewModel.makeRequestToGenius(song.title, song.artist)
}
) {
Text(stringResource(id = R.string.retry))
}
}
} else {
Results(
uiState.results,
showBottomSheet = { artistUrl, songUrl ->
showBottomSheet = !(artistUrl == Uri.EMPTY && songUrl == Uri.EMPTY)
artistUri = artistUrl
songUri = songUrl
},
navigateToLyrics = navigateToLyrics,
setLyricsTitle = { id, title, subtitle, url ->
lyricsViewModel.onSongChange(id, title, subtitle, url, getLyricsFromSpotify)
},
)
}
}
}
}
}
Column(
Modifier.height(36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val linkColor = MaterialTheme.colorScheme.surfaceTint
val text = annotatedStringResource(id = R.string.powered_by) {
when (it.key) {
"href" -> SpanStyle(color = linkColor)
else -> null
}
}
val textColor = LocalContentColor.current
val uriHandler = LocalUriHandler.current
ClickableText(
text,
onClick = { offset ->
text.getStringAnnotations(tag = "href", start = offset, end = offset)
.firstOrNull()
?.let {
uriHandler.openUri(it.item)
}
},
style = MaterialTheme.typography.bodyMedium.copy(color = textColor, fontStyle = FontStyle.Italic),
)
}
}
}
if (showBottomSheet) {
LongClickBottomSheet(
onDismiss = {
scope.launch {
sheetState.hide()
showBottomSheet = false
}
},
sheetState = sheetState,
artistUri = artistUri,
songUri = songUri,
)
}
}
}
@Composable
fun Results(
results: List<SearchResult>,
navigateToLyrics: () -> Unit,
setLyricsTitle: (id: String, title: String, subtitle: String, url: Uri) -> Unit,
showBottomSheet: (artistUrl: Uri, songUrl: Uri) -> Unit,
) {
if (results.isEmpty()) {
Text(stringResource(id = R.string.no_results), style = MaterialTheme.typography.titleLarge)
} else {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
stringResource(id = R.string.found_results, results.size),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic,
)
LazyColumn {
items(results) {
ResultListItem(
it,
onClick = {
setLyricsTitle(it.id, it.title, it.artist, it.url)
navigateToLyrics()
},
onLongClick = {
showBottomSheet(it.artistUrl, it.url)
}
)
if (it != results.last())
Divider()
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongClickBottomSheet(
onDismiss: () -> Unit,
sheetState: SheetState,
artistUri: Uri,
songUri: Uri,
) {
val uriHandler = LocalUriHandler.current
ModalBottomSheet(
dragHandle = {},
onDismissRequest = onDismiss,
sheetState = sheetState,
windowInsets = WindowInsets(top = 0.dp),
) {
Column {
if (artistUri != Uri.EMPTY) {
Row(
Modifier
.fillMaxWidth()
.clickable { uriHandler.openUri(artistUri.toString()) }
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.LibraryMusic, contentDescription = "Music Library")
Text(stringResource(id = R.string.open_artist_page), fontWeight = FontWeight.Bold)
}
}
if (songUri != Uri.EMPTY) {
Row(
Modifier
.fillMaxWidth()
.clickable { uriHandler.openUri(songUri.toString()) }
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.MusicNote, contentDescription = "Music Note")
Text(stringResource(id = R.string.open_song_page), fontWeight = FontWeight.Bold)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ResultListItem(
item: SearchResult,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
val haptic = LocalHapticFeedback.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
)
) {
val color = LocalContentColor.current
val density = LocalDensity.current
AsyncImage(
item.thumbnail,
error = forwardingPainter(
rememberVectorPainter(image = Icons.Filled.BrokenImage),
colorFilter = ColorFilter.tint(color),
size = with(density) { DpSize(40.dp, 40.dp).toSize() },
),
fallback = rememberVectorPainter(Icons.Filled.Image),
contentDescription = "Thumbnail for ${item.title}",
modifier = Modifier.size(75.dp),
contentScale = ContentScale.Crop,
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
item.title,
fontSize = TextUnit(18F, TextUnitType.Sp),
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
item.artist,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
@Preview(showBackground = true)
fun ResultListItemPreview() {
ResultListItem(
item = SearchResult(
"Test",
"Artist",
id = "1",
url = Uri.EMPTY,
artistUrl = Uri.EMPTY,
thumbnail = Uri.EMPTY
),
onClick = {},
onLongClick = {},
)
}
@@ -0,0 +1,152 @@
package com.illusionman1212.lyricsgrabbr.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.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.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.viewmodels.LyricsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LyricsPage(
goBack: () -> Unit,
lyricsViewModel: LyricsViewModel = viewModel(
factory = LyricsViewModel.Factory
),
) {
val uiState = lyricsViewModel.uiState.collectAsStateWithLifecycle().value
LaunchedEffect(uiState.song, uiState.artist, uiState.url) {
if (uiState.useSpotifyApi) {
lyricsViewModel.fetchLyricsFromSpotify()
} else {
lyricsViewModel.fetchLyricsFromGenius()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
uiState.song,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
uiState.artist,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
navigationIcon = {
LGIconButton(
tooltip = stringResource(id = R.string.go_back),
onClick = goBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(
id = R.string.go_back
)
)
}
},
)
},
) { padding ->
Column(
Modifier
.padding(padding)
.fillMaxSize()
.then(
if (!uiState.isLoading && uiState.lyrics.isNotEmpty()) Modifier.verticalScroll(
rememberScrollState()
) else Modifier
),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (uiState.isLoading) {
CircularProgressIndicator()
} 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,
),
)
}
}
}
}
}
}
}
@Composable
@Preview(showBackground = true)
fun Instrumental() {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Filled.MusicNote,
contentDescription = "Music Note",
modifier = Modifier.size(50.dp)
)
Text(
stringResource(id = R.string.instrumental),
fontStyle = FontStyle.Italic
)
}
}
@@ -0,0 +1,404 @@
package com.illusionman1212.lyricsgrabbr.ui.screens
import android.app.Activity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
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
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.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
import com.adamratzman.spotify.SpotifyClientApi
import com.adamratzman.spotify.SpotifyException
import com.adamratzman.spotify.auth.pkce.startSpotifyClientPkceLoginActivity
import com.illusionman1212.lyricsgrabbr.LGApp
import com.illusionman1212.lyricsgrabbr.R
import com.illusionman1212.lyricsgrabbr.SpotifyPkceLoginActivityImpl
import com.illusionman1212.lyricsgrabbr.ui.components.LGAlertDialog
import com.illusionman1212.lyricsgrabbr.ui.components.LGRadioButton
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.viewmodels.SettingsState
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
import kotlinx.coroutines.runBlocking
private val Themes = hashMapOf(
Theme.LIGHT.ordinal to R.string.theme_light,
Theme.DARK.ordinal to R.string.theme_dark,
Theme.SYSTEM.ordinal to R.string.theme_system
)
// TODO: this is here as a reference because I think I will need a guard in the future
fun <T> Activity.guardValidSpotifyApi(
classBackTo: Class<out Activity>,
alreadyTriedToReauthenticate: Boolean = false,
block: suspend (api: SpotifyClientApi) -> T
): T? {
return runBlocking {
val app = (application as LGApp)
try {
val token = app.credentialStore.spotifyToken
?: throw SpotifyException.ReAuthenticationNeededException()
val usesPkceAuth = token.refreshToken != null
val api = (if (usesPkceAuth) app.credentialStore.getSpotifyClientPkceApi()
else app.credentialStore.getSpotifyImplicitGrantApi())
?: throw SpotifyException.ReAuthenticationNeededException()
block(api)
} catch (e: SpotifyException) {
e.printStackTrace()
val api = app.credentialStore.getSpotifyClientPkceApi()!!
if (!alreadyTriedToReauthenticate) {
try {
api.refreshToken()
app.credentialStore.spotifyToken = api.token
block(api)
} catch (e: SpotifyException.ReAuthenticationNeededException) {
e.printStackTrace()
return@runBlocking guardValidSpotifyApi(
classBackTo = classBackTo,
alreadyTriedToReauthenticate = true,
block = block
)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
return@runBlocking guardValidSpotifyApi(
classBackTo = classBackTo,
alreadyTriedToReauthenticate = true,
block = block
)
}
} else {
//pkceClassBackTo = classBackTo
startSpotifyClientPkceLoginActivity(SpotifyPkceLoginActivityImpl::class.java)
null
}
}
}
}
@Composable
fun SettingsPage(
goBack: () -> Unit,
viewModel: SettingsViewModel,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
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(
id = R.string.go_back
) )
}
Text(text = stringResource(id = R.string.settings), style = MaterialTheme.typography.titleLarge)
}
}
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Appearance(viewModel, uiState)
Lyrics(viewModel, uiState)
}
}
}
@Composable
fun Appearance(viewModel: SettingsViewModel, uiState: SettingsState) {
var themeOpenDialog by remember { mutableStateOf(false) }
SettingGroup(title = stringResource(id = R.string.appearance)) {
DialogSetting(
title = stringResource(id = R.string.theme),
value = Themes[uiState.appTheme]?.let { stringResource(it) } ?: "None",
icon = Icons.Outlined.Palette,
onClick = { themeOpenDialog = true }
)
}
if (themeOpenDialog) {
ThemeDialog(
uiState = uiState,
onDismiss = { themeOpenDialog = false },
onThemeChange = {
viewModel.setTheme(it)
}
)
}
}
@Composable
fun ThemeDialog(
uiState: SettingsState,
onDismiss: () -> Unit,
onThemeChange: (v: Theme) -> Unit
) {
LGAlertDialog(
onDismiss = onDismiss,
title = stringResource(id = R.string.theme),
buttons = {
TextButton(onClick = onDismiss) {
Text(stringResource(id = R.string.cancel))
}
},
modifier = Modifier.fillMaxWidth()
) {
Column {
LGRadioButton(
text = stringResource(id = R.string.theme_light),
onClick = {
onThemeChange(Theme.LIGHT)
},
selected = uiState.appTheme == Theme.LIGHT.ordinal,
)
LGRadioButton(
text = stringResource(id = R.string.theme_dark),
onClick = {
onThemeChange(Theme.DARK)
},
selected = uiState.appTheme == Theme.DARK.ordinal,
)
LGRadioButton(
text = stringResource(id = R.string.theme_system),
onClick = {
onThemeChange(Theme.SYSTEM)
},
selected = uiState.appTheme == Theme.SYSTEM.ordinal,
)
}
}
}
@Composable
fun Lyrics(viewModel: SettingsViewModel, uiState: SettingsState) {
val context = LocalContext.current
val activity = context as Activity
val application = activity.application as LGApp
var isDialogOpen by remember { mutableStateOf(false) }
var spotifyIdValue by remember { mutableStateOf("") }
SettingGroup(stringResource(id = R.string.lyrics)) {
ToggleSetting(
title = stringResource(id = R.string.use_spotify_api),
checked = uiState.isSpotifyAuthed,
icon = painterResource(id = R.drawable.spotify),
onClick = {
if (uiState.isSpotifyAuthed) {
application.credentialStore.clear()
viewModel.updateSpotifyAuthStatus(false)
} else {
isDialogOpen = true
}
},
)
}
if (isDialogOpen) {
LGAlertDialog(
onDismiss = { isDialogOpen = false },
title = stringResource(id = R.string.spotify_client_id),
buttons = {
TextButton(onClick = { isDialogOpen = false }) {
Text(stringResource(id = R.string.cancel))
}
TextButton(onClick = {
SpotifyPkceLoginActivityImpl.CLIENT_ID = spotifyIdValue
SpotifyPkceLoginActivityImpl.settingsViewModel = viewModel
activity.startSpotifyClientPkceLoginActivity(SpotifyPkceLoginActivityImpl::class.java)
isDialogOpen = false
}) {
Text(stringResource(id = R.string.confirm))
}
}
) {
val linkColor = MaterialTheme.colorScheme.surfaceTint
val spotifyDesc = annotatedStringResource(R.string.spotify_client_id_desc) {
when (it.key) {
"href" -> SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)
else -> null
}
}
val textColor = LocalContentColor.current
val uriHandler = LocalUriHandler.current
ClickableText(
spotifyDesc,
onClick = { offset ->
spotifyDesc.getStringAnnotations(tag = "href", start = offset, end = offset)
.firstOrNull()
?.let {
uriHandler.openUri(it.item)
}
},
style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
)
TextField(
value = spotifyIdValue,
onValueChange = { spotifyIdValue = it },
placeholder = {
Text(stringResource(id = R.string.not_set))
},
visualTransformation = PasswordVisualTransformation(),
)
}
}
}
@Composable
fun SettingGroup(title: String, content: @Composable () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Header(title)
Spacer(modifier = Modifier.size(2.dp))
content()
}
}
@Composable
fun Header(title: String) {
Row(Modifier.padding(start = 56.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.primary)
}
}
@Composable
fun DialogSetting(
title: String,
value: String,
icon: ImageVector?,
onClick: () -> Unit,
singleLine : Boolean = false,
enabled: Boolean = true
) {
Box(
Modifier
.fillMaxWidth()
.alpha(if (enabled) 1f else 0.5f)
.then(if (enabled) Modifier.clickable { onClick() } else Modifier)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp)
) {
if (icon != null) {
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.width(56.dp))
} else {
Box(modifier = Modifier.width(56.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.onBackground)
Text(
text = value,
color = ColorSecondaryLight,
fontSize = 14.sp,
maxLines = if (singleLine) 1 else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(end = 24.dp)
)
}
}
}
}
@Composable
fun ToggleSetting(
title: String,
icon: Painter? = null,
checked: Boolean,
onClick: (Boolean) -> Unit)
{
Box(
modifier = Modifier.clickable onClick@{
onClick(!checked)
},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(top = 8.dp, end = 24.dp, bottom = 8.dp),
) {
if (icon != null) {
Icon(painter = icon, contentDescription = null, Modifier.width(56.dp))
} else {
Box(modifier = Modifier.width(56.dp))
}
Text(text = title, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1.0f))
Switch(
checked = checked,
onCheckedChange = null,
thumbContent = if (checked) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
} else null
)
}
}
}
@@ -0,0 +1,93 @@
package com.illusionman1212.lyricsgrabbr.ui.theme
import androidx.compose.ui.graphics.Color
//val Purple80 = Color(0xFFD0BCFF)
//val PurpleGrey80 = Color(0xFFCCC2DC)
//val Pink80 = Color(0xFFEFB8C8)
//
//val Purple40 = Color(0xFF6650a4)
//val PurpleGrey40 = Color(0xFF625b71)
//val Pink40 = Color(0xFF7D5260)
//
//val ColorSecondaryLight = Color(0xFF999999)
val ColorPrimaryLight = Color(0xFFFFFFFF)
val ColorPrimaryVariantLight = Color(0xFFEFEFEF)
val ColorOnPrimaryLight = Color(0xFF000000)
val ColorSecondaryLight = Color(0xFF999999)
val ColorSecondaryVariantLight = Color(0xFF888888)
val ColorOnSecondaryLight = Color(0xFF111111)
val ColorAmoledLight = Color(0xFFFFFFFF)
val ColorPrimaryDark = Color(0xFF222222)
val ColorPrimaryVariantDark = Color(0xFF202020)
val ColorOnPrimaryDark = Color(0xFFFFFFFF)
val ColorSecondaryDark = Color(0xFF696969)
val ColorSecondaryVariantDark = Color(0xFF515151)
val ColorOnSecondaryDark = Color(0xDFFFFFFF)
val ColorAmoledDark = Color(0xFF000000)
val md_theme_light_primary = Color(0xFF7C44A8)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFF2DAFF)
val md_theme_light_onPrimaryContainer = Color(0xFF2E004D)
val md_theme_light_secondary = Color(0xFF675A6E)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFEEDDF5)
val md_theme_light_onSecondaryContainer = Color(0xFF221829)
val md_theme_light_tertiary = Color(0xFF99405F)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFFFD9E1)
val md_theme_light_onTertiaryContainer = Color(0xFF3F001C)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFFFBFF)
val md_theme_light_onBackground = Color(0xFF1D1B1E)
val md_theme_light_surface = Color(0xFFFFFBFF)
val md_theme_light_onSurface = Color(0xFF1D1B1E)
val md_theme_light_surfaceVariant = Color(0xFFEADFEA)
val md_theme_light_onSurfaceVariant = Color(0xFF4B454D)
val md_theme_light_outline = Color(0xFF7C757E)
val md_theme_light_inverseOnSurface = Color(0xFFF6EFF3)
val md_theme_light_inverseSurface = Color(0xFF322F33)
val md_theme_light_inversePrimary = Color(0xFFE1B6FF)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF7C44A8)
val md_theme_light_outlineVariant = Color(0xFFCDC3CE)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFFE1B6FF)
val md_theme_dark_onPrimary = Color(0xFF4B0A76)
val md_theme_dark_primaryContainer = Color(0xFF632A8E)
val md_theme_dark_onPrimaryContainer = Color(0xFFF2DAFF)
val md_theme_dark_secondary = Color(0xFFD2C1D9)
val md_theme_dark_onSecondary = Color(0xFF372C3F)
val md_theme_dark_secondaryContainer = Color(0xFF4E4256)
val md_theme_dark_onSecondaryContainer = Color(0xFFEEDDF5)
val md_theme_dark_tertiary = Color(0xFFFFB1C6)
val md_theme_dark_onTertiary = Color(0xFF5E1131)
val md_theme_dark_tertiaryContainer = Color(0xFF7B2947)
val md_theme_dark_onTertiaryContainer = Color(0xFFFFD9E1)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1D1B1E)
val md_theme_dark_onBackground = Color(0xFFE7E1E5)
val md_theme_dark_surface = Color(0xFF1D1B1E)
val md_theme_dark_onSurface = Color(0xFFE7E1E5)
val md_theme_dark_surfaceVariant = Color(0xFF4B454D)
val md_theme_dark_onSurfaceVariant = Color(0xFFCDC3CE)
val md_theme_dark_outline = Color(0xFF968E98)
val md_theme_dark_inverseOnSurface = Color(0xFF1D1B1E)
val md_theme_dark_inverseSurface = Color(0xFFE7E1E5)
val md_theme_dark_inversePrimary = Color(0xFF7C44A8)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFFE1B6FF)
val md_theme_dark_outlineVariant = Color(0xFF4B454D)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF662D91)
@@ -0,0 +1,138 @@
package com.illusionman1212.lyricsgrabbr.ui.theme
import android.app.Activity
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.DisposableEffect
private val LightColorScheme = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
private val DarkColorScheme = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
@Composable
fun LyricsGrabbrTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb()
// allows pages to extend to the status bar
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
DisposableEffect(darkTheme) {
(context as ComponentActivity).enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT
),
navigationBarStyle = SystemBarStyle.auto(
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT
)
)
onDispose {}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,34 @@
package com.illusionman1212.lyricsgrabbr.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,58 @@
package com.illusionman1212.lyricsgrabbr.utils
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Response
import okhttp3.Callback
import okio.IOException
import kotlin.coroutines.resumeWithException
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
return suspendCancellableCoroutine { continuation ->
val callback =
object : Callback {
override fun onResponse(call: Call, response: Response) {
continuation.resume(response) {
response.body?.close()
}
}
override fun onFailure(call: Call, e: IOException) {
// Don't bother with resuming the continuation if it is already cancelled.
if (continuation.isCancelled) return
val exception = IOException(e.message, e).apply { stackTrace = callStack }
continuation.resumeWithException(exception)
}
}
enqueue(callback)
continuation.invokeOnCancellation {
try {
cancel()
} catch (ex: Throwable) {
// Ignore cancel exception
}
}
}
}
suspend fun Call.await(): Response {
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
return await(callStack)
}
suspend fun Call.awaitSuccess(): Response {
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
val response = await(callStack)
if (!response.isSuccessful) {
response.close()
throw HttpException(response.code).apply { stackTrace = callStack }
}
return response
}
class HttpException(code: Int) : IllegalStateException("HTTP error $code")
@@ -0,0 +1,34 @@
package com.illusionman1212.lyricsgrabbr.utils
import android.text.Annotation
import android.text.SpannedString
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
// Native stylization is coming soon
// https://issuetracker.google.com/issues/139320238
@Composable
fun annotatedStringResource(
@StringRes id: Int,
spanStyles: (Annotation) -> SpanStyle? = { null }
): AnnotatedString {
val resources = LocalContext.current.resources
val spannedString = SpannedString(resources.getText(id))
val resultBuilder = AnnotatedString.Builder()
resultBuilder.append(spannedString.toString())
spannedString.getSpans<Annotation>(0, spannedString.length, Annotation::class.java).forEach { annotation ->
val spanStart = spannedString.getSpanStart(annotation)
val spanEnd = spannedString.getSpanEnd(annotation)
resultBuilder.addStringAnnotation(
tag = annotation.key,
annotation = annotation.value,
start = spanStart,
end = spanEnd
)
spanStyles(annotation)?.let { resultBuilder.addStyle(it, spanStart, spanEnd) }
}
return resultBuilder.toAnnotatedString()
}
@@ -0,0 +1,170 @@
package com.illusionman1212.lyricsgrabbr.viewmodels
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.adamratzman.spotify.SpotifyClientApi
import com.illusionman1212.lyricsgrabbr.LGApp
import com.illusionman1212.lyricsgrabbr.NotificationEvent
import com.illusionman1212.lyricsgrabbr.R
import com.illusionman1212.lyricsgrabbr.data.HomeRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.*
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
object UriSerializer : KSerializer<Uri> {
override val descriptor: SerialDescriptor = String.serializer().descriptor
override fun deserialize(decoder: Decoder): Uri {
return Uri.parse(decoder.decodeString())
}
override fun serialize(encoder: Encoder, value: Uri) {
encoder.encodeString(value.toString())
}
}
data class SearchResult(
val title: String,
val artist: String,
val url: Uri,
val artistUrl: Uri,
val thumbnail: Uri,
val id: String,
)
@Serializable
data class Resources(
@Serializable(with = UriSerializer::class)
val thumbnail: Uri,
)
@Serializable
data class Artist(
val name: String,
@Serializable(with = UriSerializer::class)
val url: Uri,
)
@Serializable
data class Meta(
val title: String,
val primaryArtist: Artist,
)
@Serializable
data class IntermediateResult(
@Serializable(with = UriSerializer::class)
val url: Uri,
val meta: Meta,
val resources: Resources,
val id: Int,
)
@Serializable
data class SearchResultsResponse(
val all: List<IntermediateResult>
)
data class HomeState(
val isLoading: Boolean = false,
val results: List<SearchResult> = emptyList(),
val notification: NotificationEvent? = null,
val error: String? = null,
)
class HomeViewModel(private val context: Context, private val homeRepository: HomeRepository): ViewModel() {
private val _uiState = MutableStateFlow(HomeState())
val uiState = _uiState.asStateFlow()
private val json = Json {
ignoreUnknownKeys = true
}
fun getListeningPermission(): Boolean {
return homeRepository.isPermissionGranted()
}
fun setSong(song: NotificationEvent) {
_uiState.value = _uiState.value.copy(isLoading = true, notification = song, error = null)
}
fun makeRequestToSpotify(spotifyApi: SpotifyClientApi) {
viewModelScope.launch {
val track = spotifyApi.player.getCurrentlyPlaying()?.item
if (track?.id == null) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = context.resources.getString(R.string.no_spotify_song_playing)
)
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = null,
results = listOf(SearchResult(
title = _uiState.value.notification!!.title,
artist = _uiState.value.notification!!.artist,
url = track.asTrack?.externalUrls?.spotify?.toUri() ?: Uri.EMPTY,
artistUrl = track.asTrack?.artists?.get(0)?.externalUrls?.spotify?.toUri() ?: Uri.EMPTY,
thumbnail = track.asTrack?.album?.images?.get(0)?.url?.toUri() ?: Uri.EMPTY,
id = track.id!!,
))
)
}
}
}
fun makeRequestToGenius(song: String, artist: String) {
viewModelScope.launch {
_uiState.update { HomeState(true, uiState.value.results, uiState.value.notification, null) }
withContext(Dispatchers.IO) {
val (res, isSuccess) = homeRepository.makeSearchRequest(song, artist)
if (isSuccess) {
val results = json.decodeFromString(SearchResultsResponse.serializer(), res!!).all.map {
SearchResult(
title = it.meta.title,
artist = it.meta.primaryArtist.name,
url = it.url,
artistUrl = it.meta.primaryArtist.url,
thumbnail = it.resources.thumbnail,
id = it.id.toString()
)
}
_uiState.update { HomeState(false, results, uiState.value.notification, null) }
} else {
_uiState.update { HomeState(
false,
emptyList(),
uiState.value.notification,
res
) }
}
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as LGApp)
HomeViewModel(application.applicationContext, application.homeRepository)
}
}
}
}
@@ -0,0 +1,78 @@
package com.illusionman1212.lyricsgrabbr.viewmodels
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.illusionman1212.lyricsgrabbr.LGApp
import com.illusionman1212.lyricsgrabbr.data.LyricsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class LyricsState(
// set by Home screen
val song: String = "",
val artist: String = "",
val url: Uri = Uri.EMPTY,
val trackId: String = "",
val useSpotifyApi: Boolean = false,
// set by Lyrics screen
val lyrics: String = "",
val isLoading: Boolean = true,
val error: String? = null,
)
class LyricsViewModel(private val lyricsRepository: LyricsRepository): ViewModel() {
private val _uiState = MutableStateFlow(LyricsState())
val uiState = _uiState.asStateFlow()
fun onSongChange(trackId: String, title: String, subtitle: String, url: Uri, useSpotifyApi: Boolean) {
_uiState.update {
it.copy(song = title, artist = subtitle, url = url, useSpotifyApi = useSpotifyApi, trackId = trackId)
}
}
fun fetchLyricsFromSpotify() {
_uiState.value = _uiState.value.copy(isLoading = true, lyrics = "")
viewModelScope.launch {
withContext(Dispatchers.IO) {
val (lyrics, isSuccess) = lyricsRepository.fetchLyricsFromSpotify(uiState.value.trackId)
if (isSuccess) {
_uiState.value = _uiState.value.copy(isLoading = false, lyrics = lyrics)
} else {
_uiState.value = _uiState.value.copy(isLoading = false, error = lyrics, lyrics = "")
}
}
}
}
fun fetchLyricsFromGenius() {
_uiState.value = _uiState.value.copy(isLoading = true, lyrics = "")
viewModelScope.launch {
withContext(Dispatchers.IO) {
val lyrics = lyricsRepository.fetchLyricsFromGenius(uiState.value.url)
_uiState.value = _uiState.value.copy(isLoading = false, lyrics = lyrics)
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as LGApp)
LyricsViewModel(application.lyricsRepository)
}
}
}
}
@@ -0,0 +1,60 @@
package com.illusionman1212.lyricsgrabbr.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.illusionman1212.lyricsgrabbr.LGApp
import com.illusionman1212.lyricsgrabbr.data.SettingsPreferencesRepository
import com.illusionman1212.lyricsgrabbr.data.Theme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
data class SettingsState(
val appTheme: Int = Theme.SYSTEM.ordinal,
val isSpotifyAuthed: Boolean = false,
)
class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesRepository): ViewModel() {
private val _uiState = MutableStateFlow(SettingsState())
val uiState = _uiState.asStateFlow()
suspend fun getAppTheme(): Int {
return settingsPrefsRepo.preferences.first().appTheme
}
fun setTheme(theme: Theme) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setTheme(theme)
}
}
fun updateSpotifyAuthStatus(isAuthed: Boolean) {
_uiState.value = _uiState.value.copy(isSpotifyAuthed = isAuthed)
}
init {
viewModelScope.launch {
settingsPrefsRepo.preferences.collectLatest {
_uiState.value = _uiState.value.copy(
appTheme = it.appTheme,
)
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as LGApp)
SettingsViewModel(application.settingsPreferencesRepository)
}
}
}
}