mirror of
https://github.com/IllusionMan1212/lyrics-grabbr.git
synced 2026-10-24 02:12:27 +00:00
rewrite the app in native kotlin with compose
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
+39
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user