4 Commits
Author SHA1 Message Date
Illusion 6c167b6f6f whitelist search 2024-07-14 06:53:28 +02:00
Illusion 1a105f8413 feat: whitelist for notification listening 2024-07-12 02:36:17 +02:00
Illusion edd5b24d92 feat: settings option to change the app language 2024-07-12 02:15:33 +02:00
Illusion 7d07433e95 remove spotify as lyrics source
In order to stay on f-droid spotify has to be removed. Doing different flavors is not worth it in my opinion.
2024-07-12 02:15:33 +02:00
22 changed files with 542 additions and 410 deletions
-12
View File
@@ -8,17 +8,5 @@ App that listens to your notifications and provides lyrics for the currently pla
## Supported players
Any player that uses the MediaSession API _should_ be supported
## Using Spotify for lyrics
If you plan on using the "Use Spotify for lyrics" feature you will need to create a Spotify developer app.
You can do so [here](https://developer.spotify.com/dashboard/applications), and you should follow these steps:
- Click on "Create app" in your developer dashboard
- Name your app whatever you'd like and give it a description
- Add the following redirect URI: `lyricsgrabbr://spotify-auth`
- Select `Android` under "Which API/SDKs are you planning to use?"
- Save your newly created app
- You will be redirected to your app page where you can click on "Settings" and find your Client ID under the "Basic Information" tab
You can then use the Client ID of your app inside LyricsGrabbr to authenticate with Spotify.
## License
The project is licensed under the GPL3 license while the assets (icon) are licensed under the creative commons license.
+20 -18
View File
@@ -9,8 +9,8 @@ plugins {
}
val keystoreProperties = Properties()
val keystorePropertiesFile: File? = rootProject.file("keystore.properties")
if (keystorePropertiesFile?.exists() == true) {
val keystorePropertiesFile: File = rootProject.file("keystore.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
@@ -30,6 +30,9 @@ android {
useSupportLibrary = true
}
signingConfig = signingConfigs.getByName("debug")
// https://developer.android.com/guide/topics/resources/app-languages#gradle-config
resourceConfigurations.plus(listOf("en", "ar"))
}
signingConfigs {
@@ -107,31 +110,30 @@ android {
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.3")
implementation("androidx.activity:activity-compose:1.9.0")
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3:1.1.2")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.compose.material:material-icons-extended:1.5.4")
implementation("androidx.compose.material3:material3:1.3.0-beta04")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.compose.material:material-icons-extended:1.6.8")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.navigation:navigation-compose:2.7.6")
implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("androidx.navigation:navigation-compose:2.7.7")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation("io.coil-kt:coil:2.5.0")
implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil:2.6.0")
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("org.jsoup:jsoup:1.17.2")
implementation("com.adamratzman:spotify-api-kotlin-core:4.0.2")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2024.06.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
+14 -14
View File
@@ -6,12 +6,15 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:name=".LGApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:localeConfig="@xml/locales_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -21,7 +24,7 @@
<activity
android:name=".MainActivity"
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation"
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation|locale"
android:exported="true"
android:theme="@style/Theme.LyricsGrabbr.Splash"
>
@@ -31,19 +34,6 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".SpotifyPkceLoginActivityImpl"
android:exported="true"
android:launchMode="singleTop"
>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="lyricsgrabbr" android:host="spotify-auth" />
</intent-filter>
</activity>
<service
android:label="Lyrics Grabbr Notifications Listener"
@@ -58,5 +48,15 @@
android:name="android.app.NOTIFICATION_LISTENER"
android:value="Media playback notification listener service" />
</service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false"
>
<meta-data
android:name="autoStoreLocales"
android:value="true"
/>
</service>
</application>
</manifest>
@@ -5,7 +5,6 @@ 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
@@ -15,24 +14,15 @@ 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 = "",
redirectUri = SPOTIFY_REDIRECT_URI,
applicationContext = applicationContext
)
}
override fun onCreate() {
super.onCreate()
settingsPreferencesRepository = SettingsPreferencesRepository(dataStore)
homeRepository = HomeRepository(applicationContext)
lyricsRepository = LyricsRepository(applicationContext)
lyricsRepository = LyricsRepository()
}
}
@@ -1,6 +1,5 @@
package com.illusionman1212.lyricsgrabbr
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -9,6 +8,7 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -22,7 +22,6 @@ 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
@@ -36,6 +35,7 @@ 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.ui.screens.WhitelistPage
import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -47,10 +47,9 @@ data class NotificationEvent(
val artist: String,
val title: String,
val playbackState: Int,
val duration: Long
)
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
private var lastNotification: MutableStateFlow<NotificationEvent?> = MutableStateFlow(null)
private var isReceiverRunning = false
@@ -60,10 +59,6 @@ class MainActivity : ComponentActivity() {
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
)
@@ -83,7 +78,6 @@ class MainActivity : ComponentActivity() {
}
runBlocking {
settingsViewModel.updateSpotifyAuthStatus(application.credentialStore.spotifyToken?.refreshToken != null)
appTheme.intValue = settingsViewModel.getAppTheme()
}
@@ -137,6 +131,17 @@ class MainActivity : ComponentActivity() {
}
composable(Routes.Settings.route) {
SettingsPage(
goBack = {
navController.popBackStack()
},
navigateToWhitelist = {
navController.navigate(Routes.Whitelist.route)
},
viewModel = settingsViewModel,
)
}
composable(Routes.Whitelist.route) {
WhitelistPage(
goBack = {
navController.popBackStack()
},
@@ -182,14 +187,12 @@ class MainActivity : ComponentActivity() {
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!!,
) }
}
@@ -18,16 +18,31 @@ import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.os.BundleCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.illusionman1212.lyricsgrabbr.data.SettingsPreferencesRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class NotificationListener: NotificationListenerService() {
private var notificationManager : NotificationManagerCompat? = null
private lateinit var settingsPrefsRepo: SettingsPreferencesRepository
private val serviceScope = CoroutineScope(Dispatchers.IO)
override fun onCreate() {
super.onCreate()
initNotificationChannels(this)
notificationManager = NotificationManagerCompat.from(applicationContext)
settingsPrefsRepo = (application as LGApp).settingsPreferencesRepository
}
override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -52,8 +67,12 @@ class NotificationListener: NotificationListenerService() {
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
serviceScope.launch {
val whitelist = settingsPrefsRepo.preferences.first().whitelist
if (sbn.packageName !in whitelist) return@launch
sendNotificationBroadcast(sbn, applicationContext)
}
}
private fun getStopServiceNotificationAction(): PendingIntent {
val intent = Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -90,10 +109,15 @@ class NotificationListener: NotificationListenerService() {
private fun getCurrentNotifications(context: Context) {
val sbns = activeNotifications
serviceScope.launch {
val whitelist = settingsPrefsRepo.preferences.first().whitelist
for (sbn in sbns) {
if (sbn.packageName !in whitelist) continue
sendNotificationBroadcast(sbn, context)
}
}
}
private fun getInitialNotification(): Notification {
val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
@@ -113,14 +137,11 @@ class NotificationListener: NotificationListenerService() {
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)
val mediaSessionToken = BundleCompat.getParcelable(sbn.notification.extras, Notification.EXTRA_MEDIA_SESSION, MediaSession.Token::class.java)
?: 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)
@@ -133,7 +154,6 @@ class NotificationListener: NotificationListenerService() {
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) {
@@ -166,7 +186,6 @@ class NotificationListener: NotificationListenerService() {
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"
@@ -1,8 +1,9 @@
package com.illusionman1212.lyricsgrabbr
sealed class Routes(val route: String) {
object Home : Routes("home")
object Lyrics : Routes("lyrics")
object Settings : Routes("settings")
data object Home : Routes("home")
data object Lyrics : Routes("lyrics")
data object Settings : Routes("settings")
data object Whitelist : Routes("settings/whitelist")
}
@@ -1,29 +0,0 @@
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
}
}
@@ -1,13 +1,6 @@
package com.illusionman1212.lyricsgrabbr.data
import android.content.Context
import android.net.Uri
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 org.jsoup.Jsoup
import org.jsoup.internal.StringUtil
import org.jsoup.nodes.Element
@@ -15,58 +8,7 @@ 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()
val body = res.body?.string()
if (res.isSuccessful) {
return Pair(json.decodeFromString(SuccessResponse.serializer(), body!!)
.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)
}
}
class LyricsRepository {
fun fetchLyricsFromGenius(url: Uri): String {
val doc = Jsoup.connect(url.toString()).userAgent(USER_AGENT).get()
val divs = doc.select("div[data-lyrics-container]")
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.map
enum class Theme {
@@ -14,6 +15,7 @@ enum class Theme {
data class SettingsPreferences(
val appTheme: Int = Theme.SYSTEM.ordinal,
val whitelist: Set<String> = emptySet(),
)
class SettingsPreferencesRepository(
@@ -21,6 +23,7 @@ class SettingsPreferencesRepository(
) {
companion object {
private val APP_THEME = intPreferencesKey("app_theme")
private val WHITELIST = stringSetPreferencesKey("apps_whitelist")
}
val preferences = dataStore.data.map { mapSettingsPreferences(it) }
@@ -31,9 +34,22 @@ class SettingsPreferencesRepository(
}
}
suspend fun addToWhitelist(packageName: String) {
dataStore.edit { prefs ->
prefs[WHITELIST] = (prefs[WHITELIST]?.toMutableSet() ?: emptySet()) + packageName
}
}
suspend fun removeFromWhitelist(packageName: String) {
dataStore.edit { prefs ->
prefs[WHITELIST] = (prefs[WHITELIST]?.toMutableSet() ?: emptySet()) - packageName
}
}
private fun mapSettingsPreferences(prefs: Preferences): SettingsPreferences {
return SettingsPreferences(
prefs[APP_THEME] ?: Theme.SYSTEM.ordinal,
prefs[WHITELIST] ?: emptySet(),
)
}
}
@@ -2,17 +2,16 @@ package com.illusionman1212.lyricsgrabbr.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.PlainTooltipBox
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.ModifierInfo
import androidx.compose.ui.layout.layoutId
import com.illusionman1212.lyricsgrabbr.utils.mirror
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -22,16 +21,19 @@ fun LGIconButton(
badge: (@Composable BoxScope.() -> Unit)? = null,
content: @Composable () -> Unit
) {
val tooltipState = rememberTooltipState()
Layout(
content = {
PlainTooltipBox(
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
Text(text = tooltip)
},
state = tooltipState,
) {
IconButton(
onClick = onClick,
Modifier.tooltipAnchor(),
content = content
)
}
@@ -30,9 +30,9 @@ 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.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
@@ -48,7 +48,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -86,11 +85,9 @@ 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
@@ -107,7 +104,6 @@ fun HomePage(
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()
@@ -125,7 +121,7 @@ fun HomePage(
) }
var shouldShowNotificationPermissionRationale by remember { mutableStateOf(
ActivityCompat.shouldShowRequestPermissionRationale(
context as android.app.Activity,
context as Activity,
POST_NOTIFICATIONS
)
)}
@@ -141,7 +137,6 @@ fun HomePage(
val song = lastNotification.collectAsStateWithLifecycle().value
val uiState = homeViewModel.uiState.collectAsStateWithLifecycle().value
var getLyricsFromSpotify by rememberSaveable { mutableStateOf(false)}
LaunchedEffect(Unit) {
if (!shouldShowNotificationPermissionRationale)
@@ -159,19 +154,11 @@ fun HomePage(
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 = {
@@ -335,7 +322,7 @@ fun HomePage(
},
navigateToLyrics = navigateToLyrics,
setLyricsTitle = { id, title, subtitle, url ->
lyricsViewModel.onSongChange(id, title, subtitle, url, getLyricsFromSpotify)
lyricsViewModel.onSongChange(id, title, subtitle, url)
},
)
}
@@ -425,7 +412,7 @@ fun Results(
)
if (it != results.last())
Divider()
HorizontalDivider()
}
}
}
@@ -446,7 +433,6 @@ fun LongClickBottomSheet(
dragHandle = {},
onDismissRequest = onDismiss,
sheetState = sheetState,
windowInsets = WindowInsets(top = 0.dp),
) {
Column {
if (artistUri != Uri.EMPTY) {
@@ -54,12 +54,8 @@ fun LyricsPage(
val uiState = lyricsViewModel.uiState.collectAsStateWithLifecycle().value
LaunchedEffect(uiState.song, uiState.artist, uiState.url) {
if (uiState.useSpotifyApi) {
lyricsViewModel.fetchLyricsFromSpotify()
} else {
lyricsViewModel.fetchLyricsFromGenius()
}
}
Scaffold(
topBar = {
@@ -1,6 +1,6 @@
package com.illusionman1212.lyricsgrabbr.ui.screens
import android.app.Activity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -13,19 +13,19 @@ 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.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Translate
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.getValue
import androidx.compose.runtime.mutableStateOf
@@ -37,34 +37,21 @@ 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.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
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.utils.mirror
import com.illusionman1212.lyricsgrabbr.utils.getLangPreferenceDropdownEntries
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,
@@ -72,59 +59,10 @@ private val Themes = hashMapOf(
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,
navigateToWhitelist: () -> Unit,
viewModel: SettingsViewModel,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -141,8 +79,7 @@ fun SettingsPage(
) {
LGIconButton(tooltip = stringResource(id = R.string.go_back), onClick = goBack) {
Icon(
modifier = Modifier.mirror(),
imageVector = Icons.Filled.ArrowBack,
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(
id = R.string.go_back
) )
@@ -159,7 +96,7 @@ fun SettingsPage(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Appearance(viewModel, uiState)
Lyrics(viewModel, uiState)
Listening(navigateToWhitelist)
}
}
}
@@ -167,6 +104,7 @@ fun SettingsPage(
@Composable
fun Appearance(viewModel: SettingsViewModel, uiState: SettingsState) {
var themeOpenDialog by remember { mutableStateOf(false) }
var languageOpenDialog by remember { mutableStateOf(false) }
SettingGroup(title = stringResource(id = R.string.appearance)) {
DialogSetting(
@@ -175,6 +113,17 @@ fun Appearance(viewModel: SettingsViewModel, uiState: SettingsState) {
icon = Icons.Outlined.Palette,
onClick = { themeOpenDialog = true }
)
DialogSetting(
title = stringResource(id = R.string.language),
value = AppCompatDelegate.getApplicationLocales()[0]?.let {
AppCompatDelegate.getApplicationLocales()[0]?.getDisplayLanguage(
it
)
}
?: stringResource(id = R.string._default),
icon = Icons.Outlined.Translate,
onClick = { languageOpenDialog = true }
)
}
if (themeOpenDialog) {
ThemeDialog(
@@ -185,6 +134,40 @@ fun Appearance(viewModel: SettingsViewModel, uiState: SettingsState) {
}
)
}
if (languageOpenDialog) {
LanguageDialog(
onDismiss = { languageOpenDialog = false },
)
}
}
@Composable
private fun Listening(navigateToWhitelist: () -> Unit) {
SettingGroup(title = stringResource(id = R.string.listening)) {
Box(
Modifier
.fillMaxWidth()
.clickable { navigateToWhitelist() }
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp)
) {
Icon(imageVector = Icons.Default.Apps, contentDescription = null, modifier = Modifier.width(56.dp))
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = stringResource(id = R.string.whitelist_apps), color = MaterialTheme.colorScheme.onBackground)
Text(
text = stringResource(id = R.string.whitelist_desc),
color = ColorSecondaryLight,
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(end = 24.dp)
)
}
}
}
}
}
@Composable
@@ -230,82 +213,45 @@ fun ThemeDialog(
}
@Composable
fun Lyrics(viewModel: SettingsViewModel, uiState: SettingsState) {
val context = LocalContext.current
val activity = context as Activity
val application = activity.application as LGApp
fun LanguageDialog(
onDismiss: () -> Unit,
) {
val langs = LocalContext.current.getLangPreferenceDropdownEntries()
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),
onDismiss = onDismiss,
title = stringResource(id = R.string.language),
buttons = {
TextButton(onClick = { isDialogOpen = false }) {
TextButton(onClick = onDismiss) {
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))
}
}
},
modifier = Modifier.fillMaxWidth()
) {
val linkColor = MaterialTheme.colorScheme.surfaceTint
val spotifyDesc = annotatedStringResource(R.string.spotify_client_id_desc) {
when (it.key) {
"href" -> SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)
"weight" -> SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)
else -> null
}
}
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)
}
LazyColumn {
item {
LGRadioButton(
text = stringResource(id = R.string._default),
onClick = {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList())
},
style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
selected = AppCompatDelegate.getApplicationLocales().isEmpty,
)
TextField(
value = spotifyIdValue,
onValueChange = { spotifyIdValue = it },
placeholder = {
Text(stringResource(id = R.string.not_set))
}
for ((key, value) in langs) {
item {
LGRadioButton(
text = key,
onClick = {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(value))
},
visualTransformation = PasswordVisualTransformation(),
selected = AppCompatDelegate.getApplicationLocales()[0]?.language == value,
)
}
}
}
}
}
@Composable
fun SettingGroup(title: String, content: @Composable () -> Unit) {
@@ -317,7 +263,7 @@ fun SettingGroup(title: String, content: @Composable () -> Unit) {
}
@Composable
fun Header(title: String) {
private fun Header(title: String) {
Row(Modifier.padding(start = 56.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.primary)
}
@@ -0,0 +1,162 @@
package com.illusionman1212.lyricsgrabbr.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.illusionman1212.lyricsgrabbr.R
import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton
import com.illusionman1212.lyricsgrabbr.ui.theme.Typography
import com.illusionman1212.lyricsgrabbr.viewmodels.AppInfo
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WhitelistPage(goBack: () -> Unit, viewModel: SettingsViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(topBar = {
TopAppBar(
title = {
TextField(
value = uiState.whitelistSearchQuery,
onValueChange = {
viewModel.filterPackages(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(text = stringResource(id = R.string.search), style = Typography.titleLarge)
},
trailingIcon = {
LGIconButton(tooltip = stringResource(id = R.string.clear), onClick = { viewModel.clearWhitelistSearch() }) {
Icon(Icons.Default.Clear, contentDescription = stringResource(id = R.string.clear))
}
},
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
},
navigationIcon = {
LGIconButton(tooltip = stringResource(id = R.string.go_back), onClick = goBack) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(
id = R.string.go_back
))
}
}
)
}) { padding ->
val context = LocalContext.current
val refreshState = rememberPullToRefreshState()
var isRefreshing by remember { mutableStateOf(false) }
if (uiState.applications.isNotEmpty()) {
isRefreshing = false
}
PullToRefreshBox(
state = refreshState,
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.refreshPackages(context)
},
modifier = Modifier.padding(padding)
) {
LazyColumn(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
if (uiState.applications.any { it.value.isWhitelisted }) {
for ((packageName, appInfo) in uiState.applications.filter { it.value.isWhitelisted }) {
item {
Application(packageName, appInfo, viewModel)
}
}
item {
HorizontalDivider(
Modifier
.width(84.dp)
.padding(vertical = 12.dp))
}
}
for ((packageName, appInfo) in uiState.applications.filter { !it.value.isWhitelisted }) {
item {
Application(packageName, appInfo, viewModel)
}
}
}
}
}
}
@Composable
fun Application(packageName: String, app: AppInfo, viewModel: SettingsViewModel) {
Box(
Modifier
.fillMaxWidth()
.clickable(
role = Role.Checkbox,
onClick = {
if (app.isWhitelisted) {
viewModel.removeFromWhitelist(packageName)
} else {
viewModel.addToWhitelist(packageName)
}
}
)) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(modifier = Modifier.size(32.dp), model = app.icon, contentDescription = null)
Text(app.displayName, Modifier.weight(1f))
Checkbox(
checked = app.isWhitelisted,
onCheckedChange = null,
)
}
}
}
@@ -1,5 +1,6 @@
package com.illusionman1212.lyricsgrabbr.utils
import android.content.Context
import android.text.Annotation
import android.text.SpannedString
import androidx.annotation.StringRes
@@ -7,6 +8,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.core.os.LocaleListCompat
import com.illusionman1212.lyricsgrabbr.R
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.IOException
// Native stylization is coming soon
// https://issuetracker.google.com/issues/139320238
@@ -32,3 +38,37 @@ fun annotatedStringResource(
}
return resultBuilder.toAnnotatedString()
}
fun Context.getLocaleListFromXml(): LocaleListCompat {
val tagsList = mutableListOf<CharSequence>()
try {
val xpp: XmlPullParser = resources.getXml(R.xml.locales_config)
while (xpp.eventType != XmlPullParser.END_DOCUMENT) {
if (xpp.eventType == XmlPullParser.START_TAG) {
if (xpp.name == "locale") {
tagsList.add(xpp.getAttributeValue(0))
}
}
xpp.next()
}
} catch (e: XmlPullParserException) {
e.printStackTrace()
} catch (e: IOException) {
e.printStackTrace()
}
return LocaleListCompat.forLanguageTags(tagsList.joinToString(","))
}
fun Context.getLangPreferenceDropdownEntries(): Map<String, String> {
val localeList = getLocaleListFromXml()
val map = mutableMapOf<String, String>()
for (a in 0 until localeList.size()) {
localeList[a].let {
it?.let { it1 -> map.put(it1.getDisplayName(it), it.toLanguageTag()) }
}
}
return map
}
@@ -1,18 +1,14 @@
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
@@ -88,7 +84,7 @@ data class HomeState(
val error: String? = null,
)
class HomeViewModel(private val context: Context, private val homeRepository: HomeRepository): ViewModel() {
class HomeViewModel(private val homeRepository: HomeRepository): ViewModel() {
private val _uiState = MutableStateFlow(HomeState())
val uiState = _uiState.asStateFlow()
private val json = Json {
@@ -103,32 +99,6 @@ class HomeViewModel(private val context: Context, private val homeRepository: Ho
_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) }
@@ -163,7 +133,7 @@ class HomeViewModel(private val context: Context, private val homeRepository: Ho
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as LGApp)
HomeViewModel(application.applicationContext, application.homeRepository)
HomeViewModel(application.homeRepository)
}
}
}
@@ -22,7 +22,6 @@ data class LyricsState(
val artist: String = "",
val url: Uri = Uri.EMPTY,
val trackId: String = "",
val useSpotifyApi: Boolean = false,
// set by Lyrics screen
val lyrics: String = "",
@@ -35,24 +34,9 @@ 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) {
fun onSongChange(trackId: String, title: String, subtitle: String, url: Uri) {
_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 = "")
}
}
it.copy(song = title, artist = subtitle, url = url, trackId = trackId)
}
}
@@ -1,5 +1,8 @@
package com.illusionman1212.lyricsgrabbr.viewmodels
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -14,14 +17,23 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AppInfo(
val displayName: String,
val icon: Drawable,
var isWhitelisted: Boolean,
)
data class SettingsState(
val appTheme: Int = Theme.SYSTEM.ordinal,
val isSpotifyAuthed: Boolean = false,
val applications: Map<String, AppInfo> = emptyMap(),
val whitelistSearchQuery: String = "",
)
class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesRepository): ViewModel() {
class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesRepository, context: Context): ViewModel() {
private var immutablePackages: Map<String, AppInfo> = emptyMap()
private val _uiState = MutableStateFlow(SettingsState())
val uiState = _uiState.asStateFlow()
@@ -35,11 +47,104 @@ class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesReposi
}
}
fun updateSpotifyAuthStatus(isAuthed: Boolean) {
_uiState.value = _uiState.value.copy(isSpotifyAuthed = isAuthed)
private suspend fun getWhitelistedApps(): Set<String> {
return settingsPrefsRepo.preferences.first().whitelist
}
private fun getPackages(context: Context) {
if (immutablePackages.isNotEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
val pm = context.packageManager
val packages = pm.getInstalledApplications(PackageManager.GET_META_DATA)
.filter { it.packageName != context.packageName && pm.getLaunchIntentForPackage(it.packageName) != null }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.loadLabel(pm).toString() })
val apps: MutableMap<String, AppInfo> = mutableMapOf()
val whitelistedApps = getWhitelistedApps()
packages.forEach { pkg ->
val label = pkg.loadLabel(pm)
val icon = pkg.loadIcon(pm)
apps[pkg.packageName] = AppInfo(
displayName = label.toString(),
icon = icon,
isWhitelisted = pkg.packageName in whitelistedApps
)
}
immutablePackages = apps
_uiState.update {
SettingsState(
appTheme = uiState.value.appTheme,
applications = apps,
)
}
}
}
fun refreshPackages(context: Context) {
_uiState.update {
val apps = uiState.value.applications.toMutableMap()
apps.clear()
it.copy(whitelistSearchQuery = "", applications = apps)
}
val apps = immutablePackages.toMutableMap()
apps.clear()
immutablePackages = apps
getPackages(context)
}
fun filterPackages(query: String) {
_uiState.update { state ->
state.copy(
applications = immutablePackages.filter {
it.value.displayName.lowercase().contains(query.lowercase())
}.toMutableMap(),
whitelistSearchQuery = query,
)
}
}
fun clearWhitelistSearch() {
_uiState.update {
it.copy(applications = immutablePackages, whitelistSearchQuery = "")
}
}
fun addToWhitelist(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { state ->
val apps = immutablePackages.toMutableMap()
apps[packageName] = apps[packageName]!!.copy(isWhitelisted = true)
immutablePackages = apps
state.copy(
applications = immutablePackages.filter {
it.value.displayName.lowercase().contains(uiState.value.whitelistSearchQuery.lowercase())
}.toMutableMap(),
)
}
settingsPrefsRepo.addToWhitelist(packageName)
}
}
fun removeFromWhitelist(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { state ->
val apps = immutablePackages.toMutableMap()
apps[packageName] = apps[packageName]!!.copy(isWhitelisted = false)
immutablePackages = apps
state.copy(
applications = immutablePackages.filter {
it.value.displayName.lowercase().contains(uiState.value.whitelistSearchQuery.lowercase())
}.toMutableMap(),
)
}
settingsPrefsRepo.removeFromWhitelist(packageName)
}
}
init {
getPackages(context)
viewModelScope.launch {
settingsPrefsRepo.preferences.collectLatest {
_uiState.value = _uiState.value.copy(
@@ -53,7 +158,7 @@ class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesReposi
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as LGApp)
SettingsViewModel(application.settingsPreferencesRepository)
SettingsViewModel(application.settingsPreferencesRepository, application.applicationContext)
}
}
}
+8 -6
View File
@@ -9,7 +9,6 @@
<string name="timeout">لقد انتهت مهلة الطلب، حاول مرة أخرى لاحقًا</string>
<string name="generic_error">لقد حدث خطأ، حاول مرة أخرى لاحقاً</string>
<string name="retry">أعد المحاولة</string>
<string name="spotify_no_lyrics_error">عذرًا، لا يحتوي Spotify على كلمات لهذه الأغنية</string>
<!-- Home -->
<string name="no_supported_player">لا يوجد مشغل مدعوم قيد التشغيل</string>
@@ -25,7 +24,6 @@
<string name="notification_post_permission_desc">يتطلب هذا التطبيق أذونات الإشعارات لبدء خدمة مقدمة بغرض الاستماع إلى إشعارات الموسيقى، ولا يمكن أن يعمل بدونها.</string>
<string name="notification_post_permission_not_granted">لم يتم منح إذن الإشعارات، لن يعمل التطبيق بشكل صحيح</string>
<string name="grant_permission">منح الإذن</string>
<string name="no_spotify_song_playing">No song is playing on Spotify</string>
<!-- Settings -->
<string name="settings">الإعدادات</string>
@@ -34,10 +32,14 @@
<string name="theme_light">نهار</string>
<string name="theme_dark">ليلي</string>
<string name="theme_system">استخدم مظهر النظام</string>
<string name="use_spotify_api">استخدم Spotify للكلمات</string>
<string name="spotify_client_id">Spotify Client ID</string>
<string name="not_set">خالي</string>
<string name="spotify_client_id_desc">يمكن لهذا التطبيق استخدام واجهة برمجة تطبيقات الويب الخاصة بـ Spotify للحصول على كلمات الأغاني. تحتاج إلى تقديم Spotify Client ID لاستخدام هذه الميزة. يمكنك الحصول على واحدة من <annotation href="https://developer.spotify.com/dashboard/applications">هنا</annotation>.\n<annotation weight="bold">لا يعمل هذا إلا إذا كنت تقوم بتشغيل الموسيقى من مشغل Spotify.</annotation></string>
<string name="language">اللغة</string>
<string name="_default">الإفتراضي</string>
<string name="listening">الإستماع</string>
<string name="whitelist">القائمة البيضاء</string>
<string name="whitelist_apps">تطبيقات القائمة البيضاء</string>
<string name="whitelist_desc">اختر التطبيقات التي سيستمع التطبيق إلى إشعاراتها</string>
<string name="search">بحث</string>
<string name="clear">مسح</string>
<!-- Lyrics -->
<string name="lyrics">كلمات الأغنية</string>
+8 -6
View File
@@ -8,7 +8,6 @@
<string name="timeout">Request has timed out, try again later</string>
<string name="generic_error">An error has occurred, try again later</string>
<string name="retry">Retry</string>
<string name="spotify_no_lyrics_error">Sorry, Spotify doesn\'t have lyrics for this song</string>
<!-- Home -->
<string name="no_supported_player">No supported player is running</string>
@@ -24,7 +23,6 @@
<string name="notification_post_permission_desc">LyricsGrabbr requires notification permissions to start a foreground service for the purpose of listening to music notifications, and cannot function without it.</string>
<string name="notification_post_permission_not_granted">Notification permission is not granted, the app will not function properly</string>
<string name="grant_permission">Grant Permission</string>
<string name="no_spotify_song_playing">No song is playing on Spotify</string>
<!-- Settings -->
<string name="settings">Settings</string>
@@ -33,10 +31,14 @@
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="theme_system">Use System Theme</string>
<string name="use_spotify_api">Use Spotify for lyrics</string>
<string name="spotify_client_id" translatable="false">Spotify Client ID</string>
<string name="not_set">Not Set</string>
<string name="spotify_client_id_desc">This app can use Spotify\'s Web API to get lyrics. You need to provide a Spotify Client ID to use this feature. You can get one from <annotation href="https://developer.spotify.com/dashboard/applications">here</annotation>.\n<annotation weight="bold">This only works if you\'re playing music from the Spotify player.</annotation></string>
<string name="language">Language</string>
<string name="_default">Default</string>
<string name="listening">Listening</string>
<string name="whitelist">Whitelist</string>
<string name="whitelist_apps">Whitelist Apps</string>
<string name="whitelist_desc">Choose which apps Lyrics Grabbr will listen to</string>
<string name="search">Search</string>
<string name="clear">Clear</string>
<!-- Lyrics -->
<string name="lyrics">Lyrics</string>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ar" />
</locale-config>