6 Commits
Author SHA1 Message Date
Illusion 77d46858f1 gitignore 2024-01-19 20:40:34 +02:00
Illusion acd0a237b7 fdroid changelog and new screenshots 2024-01-19 20:33:41 +02:00
Illusion 6240c74dd4 build: split apks per ABI when building 2024-01-19 06:35:59 +02:00
Illusion a0484e9ed9 fixes
- added instructions on how to use the "Spotify for lyrics" feature in
  the README
- added ABI splits in preparation for the f-droid relase
- removed the skrape.it dependency
- fixed bug where the "This song is an instrumental" screen always shows
  after the first time of fetching lyrics from spotify
- properly position title and rotate navigation button icon on topbar for RTL
  languages
- show errors on the lyrics screen
- version 1.0.0
2024-01-19 05:56:55 +02:00
Illusion a3980c803c fix receiver not being started on subsequent app launches 2024-01-19 02:27:30 +02:00
Illusion 6f3555b1d3 rewrite the app in native kotlin with compose 2024-01-18 03:17:01 +02:00
41 changed files with 548 additions and 996 deletions
+2
View File
@@ -1,3 +1,4 @@
*.iml
.gradle .gradle
/local.properties /local.properties
/.idea/caches /.idea/caches
@@ -6,6 +7,7 @@
/.idea/workspace.xml /.idea/workspace.xml
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
.DS_Store
/build /build
/captures /captures
.externalNativeBuild .externalNativeBuild
+12
View File
@@ -8,5 +8,17 @@ App that listens to your notifications and provides lyrics for the currently pla
## Supported players ## Supported players
Any player that uses the MediaSession API _should_ be supported 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 ## License
The project is licensed under the GPL3 license while the assets (icon) are licensed under the creative commons license. The project is licensed under the GPL3 license while the assets (icon) are licensed under the creative commons license.
+20 -22
View File
@@ -9,8 +9,8 @@ plugins {
} }
val keystoreProperties = Properties() val keystoreProperties = Properties()
val keystorePropertiesFile: File = rootProject.file("keystore.properties") val keystorePropertiesFile: File? = rootProject.file("keystore.properties")
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile?.exists() == true) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile)) keystoreProperties.load(FileInputStream(keystorePropertiesFile))
} }
@@ -22,17 +22,14 @@ android {
applicationId = "com.illusionman1212.lyricsgrabbr" applicationId = "com.illusionman1212.lyricsgrabbr"
minSdk = 23 minSdk = 23
targetSdk = 34 targetSdk = 34
versionCode = 8 versionCode = 6
versionName = "1.1.1" versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
} }
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
// https://developer.android.com/guide/topics/resources/app-languages#gradle-config
resourceConfigurations.plus(listOf("en", "ar", "de"))
} }
signingConfigs { signingConfigs {
@@ -110,30 +107,31 @@ android {
dependencies { dependencies {
implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.3") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.activity:activity-compose:1.9.0") implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform("androidx.compose:compose-bom:2024.06.00")) implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3:1.3.0-beta04") implementation("androidx.compose.material3:material3:1.1.2")
implementation("com.google.android.material:material:1.12.0") implementation("com.google.android.material:material:1.11.0")
implementation("androidx.compose.material:material-icons-extended:1.6.8") implementation("androidx.compose.material:material-icons-extended:1.5.4")
implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.navigation:navigation-compose:2.7.7") implementation("androidx.navigation:navigation-compose:2.7.6")
implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation("io.coil-kt:coil:2.6.0") implementation("io.coil-kt:coil:2.5.0")
implementation("io.coil-kt:coil-compose:2.6.0") implementation("io.coil-kt:coil-compose:2.5.0")
implementation("org.jsoup:jsoup:1.17.2") implementation("org.jsoup:jsoup:1.17.2")
implementation("com.adamratzman:spotify-api-kotlin-core:4.0.2")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2024.06.00")) androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")
+14 -15
View File
@@ -6,16 +6,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application <application
android:name=".LGApp" android:name=".LGApp"
android:allowBackup="true" android:allowBackup="true"
android:usesCleartextTraffic="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:localeConfig="@xml/locales_config"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@@ -25,7 +21,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation|locale" android:configChanges="screenSize|screenLayout|keyboardHidden|orientation"
android:exported="true" android:exported="true"
android:theme="@style/Theme.LyricsGrabbr.Splash" android:theme="@style/Theme.LyricsGrabbr.Splash"
> >
@@ -35,6 +31,19 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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 <service
android:label="Lyrics Grabbr Notifications Listener" android:label="Lyrics Grabbr Notifications Listener"
@@ -49,15 +58,5 @@
android:name="android.app.NOTIFICATION_LISTENER" android:name="android.app.NOTIFICATION_LISTENER"
android:value="Media playback notification listener service" /> android:value="Media playback notification listener service" />
</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> </application>
</manifest> </manifest>
@@ -5,6 +5,7 @@ import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.adamratzman.spotify.auth.SpotifyDefaultCredentialStore
import com.illusionman1212.lyricsgrabbr.data.HomeRepository import com.illusionman1212.lyricsgrabbr.data.HomeRepository
import com.illusionman1212.lyricsgrabbr.data.LyricsRepository import com.illusionman1212.lyricsgrabbr.data.LyricsRepository
import com.illusionman1212.lyricsgrabbr.data.SettingsPreferencesRepository import com.illusionman1212.lyricsgrabbr.data.SettingsPreferencesRepository
@@ -14,15 +15,24 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = PREFERENCES_NAME name = PREFERENCES_NAME
) )
const val SPOTIFY_REDIRECT_URI = "lyricsgrabbr://spotify-auth"
class LGApp: Application() { class LGApp: Application() {
lateinit var settingsPreferencesRepository: SettingsPreferencesRepository lateinit var settingsPreferencesRepository: SettingsPreferencesRepository
lateinit var homeRepository: HomeRepository lateinit var homeRepository: HomeRepository
lateinit var lyricsRepository: LyricsRepository lateinit var lyricsRepository: LyricsRepository
val credentialStore by lazy {
SpotifyDefaultCredentialStore(
clientId = "",
redirectUri = SPOTIFY_REDIRECT_URI,
applicationContext = applicationContext
)
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
settingsPreferencesRepository = SettingsPreferencesRepository(dataStore) settingsPreferencesRepository = SettingsPreferencesRepository(dataStore)
homeRepository = HomeRepository(applicationContext) homeRepository = HomeRepository(applicationContext)
lyricsRepository = LyricsRepository() lyricsRepository = LyricsRepository(applicationContext)
} }
} }
@@ -1,13 +1,14 @@
package com.illusionman1212.lyricsgrabbr package com.illusionman1212.lyricsgrabbr
import android.app.Activity
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -21,6 +22,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -34,7 +36,6 @@ import com.illusionman1212.lyricsgrabbr.ui.screens.HomePage
import com.illusionman1212.lyricsgrabbr.ui.screens.LyricsPage import com.illusionman1212.lyricsgrabbr.ui.screens.LyricsPage
import com.illusionman1212.lyricsgrabbr.ui.theme.LyricsGrabbrTheme import com.illusionman1212.lyricsgrabbr.ui.theme.LyricsGrabbrTheme
import com.illusionman1212.lyricsgrabbr.ui.screens.SettingsPage 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.LyricsViewModel
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -46,9 +47,10 @@ data class NotificationEvent(
val artist: String, val artist: String,
val title: String, val title: String,
val playbackState: Int, val playbackState: Int,
val duration: Long
) )
class MainActivity : AppCompatActivity() { class MainActivity : ComponentActivity() {
private var lastNotification: MutableStateFlow<NotificationEvent?> = MutableStateFlow(null) private var lastNotification: MutableStateFlow<NotificationEvent?> = MutableStateFlow(null)
private var isReceiverRunning = false private var isReceiverRunning = false
@@ -58,6 +60,10 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
val context = LocalContext.current
val activity = context as Activity
val application = activity.application as LGApp
val settingsViewModel: SettingsViewModel = viewModel( val settingsViewModel: SettingsViewModel = viewModel(
factory = SettingsViewModel.Factory factory = SettingsViewModel.Factory
) )
@@ -77,6 +83,7 @@ class MainActivity : AppCompatActivity() {
} }
runBlocking { runBlocking {
settingsViewModel.updateSpotifyAuthStatus(application.credentialStore.spotifyToken?.refreshToken != null)
appTheme.intValue = settingsViewModel.getAppTheme() appTheme.intValue = settingsViewModel.getAppTheme()
} }
@@ -116,7 +123,6 @@ class MainActivity : AppCompatActivity() {
navController.navigate(Routes.Lyrics.route) navController.navigate(Routes.Lyrics.route)
}, },
lyricsViewModel = lyricsViewModel, lyricsViewModel = lyricsViewModel,
settingsViewModel = settingsViewModel,
goBack = { finish() }, goBack = { finish() },
lastNotification = lastNotification, lastNotification = lastNotification,
) )
@@ -127,22 +133,10 @@ class MainActivity : AppCompatActivity() {
navController.popBackStack() navController.popBackStack()
}, },
lyricsViewModel = lyricsViewModel, lyricsViewModel = lyricsViewModel,
settingsViewModel = settingsViewModel
) )
} }
composable(Routes.Settings.route) { composable(Routes.Settings.route) {
SettingsPage( SettingsPage(
goBack = {
navController.popBackStack()
},
navigateToWhitelist = {
navController.navigate(Routes.Whitelist.route)
},
viewModel = settingsViewModel,
)
}
composable(Routes.Whitelist.route) {
WhitelistPage(
goBack = { goBack = {
navController.popBackStack() navController.popBackStack()
}, },
@@ -188,12 +182,14 @@ class MainActivity : AppCompatActivity() {
val artist = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_ARTIST) val artist = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_ARTIST)
val songTitle = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_SONG_TITLE) val songTitle = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_SONG_TITLE)
val playbackState = intent.getIntExtra(NotificationListener.NOTIFICATION_PACKAGE_PLAYBACK_STATE, -1) 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) val notifPackageName = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_NAME)
lastNotification.update { NotificationEvent( lastNotification.update { NotificationEvent(
title = songTitle!!, title = songTitle!!,
artist = artist!!, artist = artist!!,
playbackState = playbackState, playbackState = playbackState,
duration = duration,
packageName = notifPackageName!!, packageName = notifPackageName!!,
) } ) }
} }
@@ -18,31 +18,16 @@ import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.os.BundleCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager 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() { class NotificationListener: NotificationListenerService() {
private var notificationManager : NotificationManagerCompat? = null private var notificationManager : NotificationManagerCompat? = null
private lateinit var settingsPrefsRepo: SettingsPreferencesRepository
private val serviceScope = CoroutineScope(Dispatchers.IO)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
initNotificationChannels(this) initNotificationChannels(this)
notificationManager = NotificationManagerCompat.from(applicationContext) 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 { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -67,11 +52,7 @@ class NotificationListener: NotificationListenerService() {
} }
override fun onNotificationPosted(sbn: StatusBarNotification) { override fun onNotificationPosted(sbn: StatusBarNotification) {
serviceScope.launch { sendNotificationBroadcast(sbn, applicationContext)
val whitelist = settingsPrefsRepo.preferences.first().whitelist
if (sbn.packageName !in whitelist) return@launch
sendNotificationBroadcast(sbn, applicationContext)
}
} }
private fun getStopServiceNotificationAction(): PendingIntent { private fun getStopServiceNotificationAction(): PendingIntent {
@@ -109,13 +90,8 @@ class NotificationListener: NotificationListenerService() {
private fun getCurrentNotifications(context: Context) { private fun getCurrentNotifications(context: Context) {
val sbns = activeNotifications val sbns = activeNotifications
serviceScope.launch { for (sbn in sbns) {
val whitelist = settingsPrefsRepo.preferences.first().whitelist sendNotificationBroadcast(sbn, context)
for (sbn in sbns) {
if (sbn.packageName !in whitelist) continue
sendNotificationBroadcast(sbn, context)
}
} }
} }
@@ -137,11 +113,14 @@ class NotificationListener: NotificationListenerService() {
if (ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED) {
return return
} }
// Apparently the new getParcelable API is buggy and throws an exception if used.
val mediaSessionToken = BundleCompat.getParcelable(sbn.notification.extras, Notification.EXTRA_MEDIA_SESSION, MediaSession.Token::class.java) // 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 ?: return
val mediaCtrl = MediaController(context, mediaSessionToken) val mediaCtrl = MediaController(context, mediaSessionToken)
val duration = mediaCtrl.metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.div(1000)
val playbackState = mediaCtrl.playbackState?.state val playbackState = mediaCtrl.playbackState?.state
val albumArt = mediaCtrl.metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) val albumArt = mediaCtrl.metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
@@ -154,6 +133,7 @@ class NotificationListener: NotificationListenerService() {
intent.putExtra(NOTIFICATION_PACKAGE_NAME, sbn.packageName) intent.putExtra(NOTIFICATION_PACKAGE_NAME, sbn.packageName)
intent.putExtra(NOTIFICATION_PACKAGE_ARTIST, packageArtist) intent.putExtra(NOTIFICATION_PACKAGE_ARTIST, packageArtist)
intent.putExtra(NOTIFICATION_PACKAGE_SONG_TITLE, packageSongTitle) intent.putExtra(NOTIFICATION_PACKAGE_SONG_TITLE, packageSongTitle)
intent.putExtra(NOTIFICATION_PACKAGE_DURATION, duration)
intent.putExtra(NOTIFICATION_PACKAGE_PLAYBACK_STATE, playbackState) intent.putExtra(NOTIFICATION_PACKAGE_PLAYBACK_STATE, playbackState)
if (playbackState == PlaybackState.STATE_PAUSED) { if (playbackState == PlaybackState.STATE_PAUSED) {
@@ -186,6 +166,7 @@ class NotificationListener: NotificationListenerService() {
const val NOTIFICATION_PACKAGE_ARTIST = "package_artist" const val NOTIFICATION_PACKAGE_ARTIST = "package_artist"
const val NOTIFICATION_PACKAGE_SONG_TITLE = "package_song_title" const val NOTIFICATION_PACKAGE_SONG_TITLE = "package_song_title"
const val NOTIFICATION_PACKAGE_PLAYBACK_STATE = "package_playback_state" const val NOTIFICATION_PACKAGE_PLAYBACK_STATE = "package_playback_state"
const val NOTIFICATION_PACKAGE_DURATION = "package_duration"
const val NOTIFICATION_ID = 1 const val NOTIFICATION_ID = 1
const val NOTIFICATION_CHANNEL_ID = "Lyrics" const val NOTIFICATION_CHANNEL_ID = "Lyrics"
const val START_FOREGROUND_SERVICE_ACTION = "START_SERVICE" const val START_FOREGROUND_SERVICE_ACTION = "START_SERVICE"
@@ -1,9 +1,8 @@
package com.illusionman1212.lyricsgrabbr package com.illusionman1212.lyricsgrabbr
sealed class Routes(val route: String) { sealed class Routes(val route: String) {
data object Home : Routes("home") object Home : Routes("home")
data object Lyrics : Routes("lyrics") object Lyrics : Routes("lyrics")
data object Settings : Routes("settings") object Settings : Routes("settings")
data object Whitelist : Routes("settings/whitelist")
} }
@@ -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
}
}
@@ -2,7 +2,6 @@ package com.illusionman1212.lyricsgrabbr.data
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.illusionman1212.lyricsgrabbr.R import com.illusionman1212.lyricsgrabbr.R
import com.illusionman1212.lyricsgrabbr.data.network.GET import com.illusionman1212.lyricsgrabbr.data.network.GET
@@ -11,7 +10,6 @@ import com.illusionman1212.lyricsgrabbr.utils.await
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okio.IOException import okio.IOException
import org.jsoup.SerializationException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
@Serializable @Serializable
@@ -33,9 +31,11 @@ class HomeRepository(private val context: Context) {
return pkgs.contains(packageName) return pkgs.contains(packageName)
} }
suspend fun makeSearchRequest(lContext: Context, baseUrl: String, song: String, artist: String): Pair<String?, Boolean> { suspend fun makeSearchRequest(song: String, artist: String): Pair<String?, Boolean> {
val url = Uri.parse(baseUrl) val builder = Uri.Builder()
.buildUpon() val url = builder.scheme("https")
.authority("api.illusionman1212.com")
.appendPath("lyrics")
.appendPath("search") .appendPath("search")
.appendQueryParameter("q", "$song $artist") .appendQueryParameter("q", "$song $artist")
.appendQueryParameter("disableFuzzy", "true") .appendQueryParameter("disableFuzzy", "true")
@@ -47,23 +47,11 @@ class HomeRepository(private val context: Context) {
).await() ).await()
if (!res.isSuccessful) { if (!res.isSuccessful) {
if (res.code >= 500) { val errRes = json.decodeFromString(ErrorResponse.serializer(), res.body!!.string())
return Pair(lContext.resources.getString(R.string.internal_server_error), false)
}
if (res.body?.contentType()?.type != "text" && res.body?.contentType()?.subtype != "json") { // matches is only null if there's an error. otherwise it's 0 when there are no results
return Pair(lContext.resources.getString(R.string.invalid_json), false) if (errRes.matches == null) {
} return Pair(context.resources.getString(R.string.generic_error), false)
try {
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(lContext.resources.getString(R.string.generic_error), false)
}
} catch (e: IllegalArgumentException) {
return Pair(lContext.resources.getString(R.string.failed_to_map_json), false)
} }
return Pair(null, false) return Pair(null, false)
@@ -72,10 +60,10 @@ class HomeRepository(private val context: Context) {
return Pair(res.body?.string(), true) return Pair(res.body?.string(), true)
} catch (e: IOException) { } catch (e: IOException) {
if (e.cause is SocketTimeoutException) { if (e.cause is SocketTimeoutException) {
return Pair(lContext.resources.getString(R.string.timeout), false) return Pair(context.resources.getString(R.string.timeout), false)
} }
return Pair(e.message, false) return Pair(context.resources.getString(R.string.generic_error), false)
} }
} }
} }
@@ -1,7 +1,13 @@
package com.illusionman1212.lyricsgrabbr.data package com.illusionman1212.lyricsgrabbr.data
import android.content.Context
import android.net.Uri import android.net.Uri
import org.jsoup.HttpStatusException 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.Jsoup
import org.jsoup.internal.StringUtil import org.jsoup.internal.StringUtil
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
@@ -9,64 +15,96 @@ import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode import org.jsoup.nodes.TextNode
import org.jsoup.select.NodeVisitor import org.jsoup.select.NodeVisitor
class LyricsRepository { @Serializable
fun fetchLyricsFromGenius(url: Uri): String { data class LyricLine(
return try { val startTimeMs: ULong,
val doc = Jsoup.connect(url.toString()) val words: String,
.userAgent(USER_AGENT) )
.get()
val divs = doc.select("div[data-lyrics-container]") @Serializable
val lyrics = StringUtil.borrowBuilder() data class Lyrics(
val syncType: String,
val lines: List<LyricLine>,
val language: String,
)
divs.forEach { @Serializable
it.traverse(object : NodeVisitor { data class SuccessResponse(
override fun head(node: Node, depth: Int) { val lyrics: Lyrics,
if (node is TextNode) { )
lyrics.append(node.text().trim())
// append space here to get proper spacing when there's a <i> tag or other inline text tags
lyrics.append(" ")
} else if (node is Element) {
if (lyrics.isNotEmpty() && node.normalName() == "br") {
lyrics.append("\n")
}
}
}
override fun tail(node: Node, depth: Int) { class LyricsRepository(private val context: Context) {
// since we append a space after every text node, we only append a newline if private val networkHelper = NetworkHelper(context)
// the last char isn't a space private val json = Json {
val lastCharIsWhitespace = lyrics.isNotEmpty() && lyrics[lyrics.length - 1] == ' ' ignoreUnknownKeys = true
if (node is Element) { }
if (node.isBlock && node.nextSibling() is TextNode && !lastCharIsWhitespace) {
lyrics.append("\n")
}
}
}
})
lyrics.append("\n") 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)
} }
lyrics.toString().replace("\n\n\n", "\n\n") return Pair(context.resources.getString(R.string.generic_error), false)
} catch (e: HttpStatusException) {
when (e.statusCode) {
403 -> {
println("Access Forbidden: HTTP 403")
"Error 403: Cloudflare got in the way, try disabling the VPN or Proxy if enabled"
}
else -> {
println("HTTP Error: ${e.statusCode}")
"HTTP Error: ${e.statusCode}"
}
}
} catch (e: Exception) {
println("An error occurred: ${e.message}")
"An error occurred while fetching lyrics."
} }
} }
fun fetchLyricsFromGenius(url: Uri): String {
val doc = Jsoup.connect(url.toString()).userAgent(USER_AGENT).get()
val divs = doc.select("div[data-lyrics-container]")
val lyrics = StringUtil.borrowBuilder()
divs.forEach {
it.traverse(object : NodeVisitor {
override fun head(node: Node, depth: Int) {
if (node is TextNode) {
lyrics.append(node.text().trim())
// append space here to get proper spacing when there's a <i> tag or other inline text tags
lyrics.append(" ")
} else if (node is Element) {
if (lyrics.isNotEmpty() && node.normalName() == "br") {
lyrics.append("\n")
}
}
}
override fun tail(node: Node, depth: Int) {
// since we append a space after every text node, we only append a newline if
// the last char isn't a space
val lastCharIsWhitespace = lyrics.isNotEmpty() && lyrics[lyrics.length - 1] == ' '
if (node is Element) {
if (node.isBlock && node.nextSibling() is TextNode && !lastCharIsWhitespace) {
lyrics.append("\n")
}
}
}
})
lyrics.append("\n")
}
return lyrics.toString().replace("\n\n\n", "\n\n")
}
companion object { 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" 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"
} }
@@ -2,11 +2,8 @@ package com.illusionman1212.lyricsgrabbr.data
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
enum class Theme { enum class Theme {
@@ -17,10 +14,6 @@ enum class Theme {
data class SettingsPreferences( data class SettingsPreferences(
val appTheme: Int = Theme.SYSTEM.ordinal, val appTheme: Int = Theme.SYSTEM.ordinal,
val whitelist: Set<String> = emptySet(),
val keepScreenOn: Boolean = false,
val whitelistPromptSeen: Boolean = false,
val geniURLbaseURL: String = "https://api.illusionman1212.com/lyrics",
) )
class SettingsPreferencesRepository( class SettingsPreferencesRepository(
@@ -28,10 +21,6 @@ class SettingsPreferencesRepository(
) { ) {
companion object { companion object {
private val APP_THEME = intPreferencesKey("app_theme") private val APP_THEME = intPreferencesKey("app_theme")
private val WHITELIST = stringSetPreferencesKey("apps_whitelist")
private val KEEP_SCREEN_ON = booleanPreferencesKey("keep_screen_on")
private val WHITELIST_PROMPT_SEEN = booleanPreferencesKey("whitelist_prompt_seen")
private val GENIURL_BASE_URL = stringPreferencesKey("geniurl_base_url")
} }
val preferences = dataStore.data.map { mapSettingsPreferences(it) } val preferences = dataStore.data.map { mapSettingsPreferences(it) }
@@ -42,43 +31,9 @@ class SettingsPreferencesRepository(
} }
} }
suspend fun addToWhitelist(packageName: String) {
dataStore.edit { prefs ->
prefs[WHITELIST] = (prefs[WHITELIST]?.toMutableSet() ?: emptySet()) + packageName
}
}
suspend fun toggleKeepScreenOn() {
dataStore.edit { prefs ->
prefs[KEEP_SCREEN_ON] = !(prefs[KEEP_SCREEN_ON] ?: false)
}
}
suspend fun removeFromWhitelist(packageName: String) {
dataStore.edit { prefs ->
prefs[WHITELIST] = (prefs[WHITELIST]?.toMutableSet() ?: emptySet()) - packageName
}
}
suspend fun discardWhitelistPrompt() {
dataStore.edit { prefs ->
prefs[WHITELIST_PROMPT_SEEN] = true
}
}
suspend fun setGeniURLBaseURL(url: String) {
dataStore.edit { prefs ->
prefs[GENIURL_BASE_URL] = url
}
}
private fun mapSettingsPreferences(prefs: Preferences): SettingsPreferences { private fun mapSettingsPreferences(prefs: Preferences): SettingsPreferences {
return SettingsPreferences( return SettingsPreferences(
prefs[APP_THEME] ?: Theme.SYSTEM.ordinal, prefs[APP_THEME] ?: Theme.SYSTEM.ordinal,
prefs[WHITELIST] ?: emptySet(),
prefs[KEEP_SCREEN_ON] ?: false,
prefs[WHITELIST_PROMPT_SEEN] ?: false,
prefs[GENIURL_BASE_URL] ?: "https://api.illusionman1212.com/lyrics",
) )
} }
} }
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -30,11 +29,11 @@ fun LGAlertDialog(
buttons: @Composable () -> Unit, buttons: @Composable () -> Unit,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
BasicAlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = properties properties = properties,
) { ) {
Surface( Surface (
modifier = Modifier.wrapContentHeight().wrapContentWidth(), modifier = Modifier.wrapContentHeight().wrapContentWidth(),
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
tonalElevation = AlertDialogDefaults.TonalElevation tonalElevation = AlertDialogDefaults.TonalElevation
@@ -2,16 +2,17 @@ package com.illusionman1212.lyricsgrabbr.ui.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.PlainTooltipBox
import androidx.compose.material3.Text 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.ModifierInfo
import androidx.compose.ui.layout.layoutId import androidx.compose.ui.layout.layoutId
import com.illusionman1212.lyricsgrabbr.utils.mirror
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -21,19 +22,16 @@ fun LGIconButton(
badge: (@Composable BoxScope.() -> Unit)? = null, badge: (@Composable BoxScope.() -> Unit)? = null,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val tooltipState = rememberTooltipState()
Layout( Layout(
content = { content = {
TooltipBox( PlainTooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = { tooltip = {
Text(text = tooltip) Text(text = tooltip)
}, },
state = tooltipState,
) { ) {
IconButton( IconButton(
onClick = onClick, onClick = onClick,
Modifier.tooltipAnchor(),
content = content content = content
) )
} }
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -29,9 +30,9 @@ import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -47,6 +48,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -57,6 +59,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
@@ -72,7 +75,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.currentStateAsState import androidx.lifecycle.compose.currentStateAsState
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -84,6 +86,7 @@ import com.illusionman1212.lyricsgrabbr.viewmodels.HomeViewModel
import com.illusionman1212.lyricsgrabbr.viewmodels.SearchResult import com.illusionman1212.lyricsgrabbr.viewmodels.SearchResult
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.illusionman1212.lyricsgrabbr.LGApp
import com.illusionman1212.lyricsgrabbr.ui.components.forwardingPainter import com.illusionman1212.lyricsgrabbr.ui.components.forwardingPainter
import com.illusionman1212.lyricsgrabbr.utils.annotatedStringResource import com.illusionman1212.lyricsgrabbr.utils.annotatedStringResource
import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel
@@ -101,10 +104,10 @@ fun HomePage(
factory = HomeViewModel.Factory, factory = HomeViewModel.Factory,
), ),
lyricsViewModel: LyricsViewModel, lyricsViewModel: LyricsViewModel,
settingsViewModel: SettingsViewModel,
lastNotification: MutableStateFlow<NotificationEvent?> lastNotification: MutableStateFlow<NotificationEvent?>
) { ) {
val context = LocalContext.current val context = LocalContext.current
val application = (context as Activity).application as LGApp
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState() val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState()
@@ -117,13 +120,12 @@ fun HomePage(
var songUri by remember { mutableStateOf(Uri.EMPTY) } var songUri by remember { mutableStateOf(Uri.EMPTY) }
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var listeningPermission = homeViewModel.getListeningPermission() var listeningPermission = homeViewModel.getListeningPermission()
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
var notificationPermission by remember { mutableStateOf( var notificationPermission by remember { mutableStateOf(
ActivityCompat.checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED ActivityCompat.checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED
) } ) }
var shouldShowNotificationPermissionRationale by remember { mutableStateOf( var shouldShowNotificationPermissionRationale by remember { mutableStateOf(
ActivityCompat.shouldShowRequestPermissionRationale( ActivityCompat.shouldShowRequestPermissionRationale(
context as Activity, context as android.app.Activity,
POST_NOTIFICATIONS POST_NOTIFICATIONS
) )
)} )}
@@ -139,6 +141,7 @@ fun HomePage(
val song = lastNotification.collectAsStateWithLifecycle().value val song = lastNotification.collectAsStateWithLifecycle().value
val uiState = homeViewModel.uiState.collectAsStateWithLifecycle().value val uiState = homeViewModel.uiState.collectAsStateWithLifecycle().value
var getLyricsFromSpotify by rememberSaveable { mutableStateOf(false)}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!shouldShowNotificationPermissionRationale) if (!shouldShowNotificationPermissionRationale)
@@ -156,9 +159,17 @@ fun HomePage(
LaunchedEffect(song) { LaunchedEffect(song) {
if (song != null && song.title != uiState.notification?.title && song.artist != uiState.notification?.artist) { 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) homeViewModel.setSong(song)
homeViewModel.makeRequestToGenius(context, song.title, song.artist) if (useSpotifyApi) {
homeViewModel.makeRequestToSpotify(spotifyApi!!)
} else {
homeViewModel.makeRequestToGenius(song.title, song.artist)
}
} }
} }
@@ -240,30 +251,6 @@ fun HomePage(
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
} }
} else if (!settingsState.whitelistPromptSeen) {
LGAlertDialog(
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
),
onDismiss = { settingsViewModel.discardWhitelistPrompt() },
title = stringResource(id = R.string.whitelist_apps),
buttons = {
TextButton(
onClick = {
settingsViewModel.discardWhitelistPrompt()
}
) {
Text(stringResource(id = R.string.understood))
}
}
) {
Text(
text = stringResource(R.string.whitelist_prompt_desc),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
}
} }
Column( Column(
@@ -275,11 +262,7 @@ fun HomePage(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text( text = stringResource(id = R.string.no_supported_player), style = MaterialTheme.typography.headlineSmall )
text = stringResource(id = R.string.no_supported_player),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
if (!notificationPermission) { if (!notificationPermission) {
Text( Text(
text = stringResource(id = R.string.notification_post_permission_not_granted).uppercase(), text = stringResource(id = R.string.notification_post_permission_not_granted).uppercase(),
@@ -336,7 +319,7 @@ fun HomePage(
) )
FilledTonalButton( FilledTonalButton(
onClick = { onClick = {
homeViewModel.makeRequestToGenius(context, song.title, song.artist) homeViewModel.makeRequestToGenius(song.title, song.artist)
} }
) { ) {
Text(stringResource(id = R.string.retry)) Text(stringResource(id = R.string.retry))
@@ -352,7 +335,7 @@ fun HomePage(
}, },
navigateToLyrics = navigateToLyrics, navigateToLyrics = navigateToLyrics,
setLyricsTitle = { id, title, subtitle, url -> setLyricsTitle = { id, title, subtitle, url ->
lyricsViewModel.onSongChange(id, title, subtitle, url) lyricsViewModel.onSongChange(id, title, subtitle, url, getLyricsFromSpotify)
}, },
) )
} }
@@ -442,7 +425,7 @@ fun Results(
) )
if (it != results.last()) if (it != results.last())
HorizontalDivider() Divider()
} }
} }
} }
@@ -463,6 +446,7 @@ fun LongClickBottomSheet(
dragHandle = {}, dragHandle = {},
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState, sheetState = sheetState,
windowInsets = WindowInsets(top = 0.dp),
) { ) {
Column { Column {
if (artistUri != Uri.EMPTY) { if (artistUri != Uri.EMPTY) {
@@ -1,14 +1,8 @@
package com.illusionman1212.lyricsgrabbr.ui.screens package com.illusionman1212.lyricsgrabbr.ui.screens
import android.app.Activity
import android.view.WindowManager.LayoutParams
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -16,7 +10,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -29,12 +23,10 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
@@ -44,13 +36,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.illusionman1212.lyricsgrabbr.R import com.illusionman1212.lyricsgrabbr.R
import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton
import com.illusionman1212.lyricsgrabbr.utils.mirror
import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -59,24 +50,14 @@ fun LyricsPage(
lyricsViewModel: LyricsViewModel = viewModel( lyricsViewModel: LyricsViewModel = viewModel(
factory = LyricsViewModel.Factory factory = LyricsViewModel.Factory
), ),
settingsViewModel: SettingsViewModel,
) { ) {
val uiState = lyricsViewModel.uiState.collectAsStateWithLifecycle().value val uiState = lyricsViewModel.uiState.collectAsStateWithLifecycle().value
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val context = LocalContext.current
LaunchedEffect(uiState.song, uiState.artist, uiState.url) { LaunchedEffect(uiState.song, uiState.artist, uiState.url) {
lyricsViewModel.fetchLyricsFromGenius() if (uiState.useSpotifyApi) {
} lyricsViewModel.fetchLyricsFromSpotify()
} else {
DisposableEffect(Unit) { lyricsViewModel.fetchLyricsFromGenius()
val window = (context as Activity).window
if (settingsState.keepScreenOn) {
window.addFlags(LayoutParams.FLAG_KEEP_SCREEN_ON)
}
onDispose {
window.clearFlags(LayoutParams.FLAG_KEEP_SCREEN_ON)
} }
} }
@@ -105,7 +86,8 @@ fun LyricsPage(
onClick = goBack onClick = goBack
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, modifier = Modifier.mirror(),
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource( contentDescription = stringResource(
id = R.string.go_back id = R.string.go_back
) )
@@ -115,63 +97,53 @@ fun LyricsPage(
) )
}, },
) { padding -> ) { padding ->
Column(Modifier.padding(padding)) { Column(
if (settingsState.keepScreenOn) { Modifier
Row( .padding(padding)
Modifier .fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer) .then(
.fillMaxWidth(), if (!uiState.isLoading && uiState.lyrics.isNotEmpty()) Modifier.verticalScroll(
horizontalArrangement = Arrangement.Center, rememberScrollState()
) { ) else Modifier
Text(stringResource(id = R.string.screen_will_stay_on).uppercase(), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSecondaryContainer) ),
} verticalArrangement = Arrangement.Center,
} horizontalAlignment = Alignment.CenterHorizontally,
Column( ) {
Modifier if (uiState.isLoading) {
.fillMaxSize() CircularProgressIndicator()
.then( } else {
if (!uiState.isLoading && uiState.lyrics.isNotEmpty()) Modifier.verticalScroll( if (uiState.error != null) {
rememberScrollState() Text(
) else Modifier uiState.error,
), style = MaterialTheme.typography.bodyMedium,
verticalArrangement = Arrangement.Center, modifier = Modifier.padding(bottom = 32.dp)
horizontalAlignment = Alignment.CenterHorizontally, )
) { } else if (uiState.lyrics.isEmpty()) {
if (uiState.isLoading) { Instrumental()
CircularProgressIndicator()
} else { } else {
if (uiState.error != null) {
Text( CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
uiState.error, Column(
style = MaterialTheme.typography.bodyMedium, Modifier.padding(
modifier = Modifier.padding(bottom = 32.dp) top = 12.dp,
) start = 12.dp,
} else if (uiState.lyrics.isEmpty()) { end = 12.dp,
Instrumental() bottom = 32.dp
} else { )
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { ) {
Column( SelectionContainer {
Modifier.padding( TextField(
top = 12.dp, value = uiState.lyrics,
start = 12.dp, onValueChange = {},
end = 12.dp, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
bottom = 32.dp readOnly = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
) )
) {
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,
),
)
}
} }
} }
} }
@@ -1,7 +1,6 @@
package com.illusionman1212.lyricsgrabbr.ui.screens package com.illusionman1212.lyricsgrabbr.ui.screens
import android.util.Patterns import android.app.Activity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -14,16 +13,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ScreenLockPortrait
import androidx.compose.material.icons.outlined.Cloud
import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@@ -38,23 +34,37 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext 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.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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.R
import com.illusionman1212.lyricsgrabbr.SpotifyPkceLoginActivityImpl
import com.illusionman1212.lyricsgrabbr.ui.components.LGAlertDialog import com.illusionman1212.lyricsgrabbr.ui.components.LGAlertDialog
import com.illusionman1212.lyricsgrabbr.ui.components.LGRadioButton import com.illusionman1212.lyricsgrabbr.ui.components.LGRadioButton
import com.illusionman1212.lyricsgrabbr.ui.theme.ColorSecondaryLight import com.illusionman1212.lyricsgrabbr.ui.theme.ColorSecondaryLight
import com.illusionman1212.lyricsgrabbr.data.Theme import com.illusionman1212.lyricsgrabbr.data.Theme
import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton
import com.illusionman1212.lyricsgrabbr.utils.getLangPreferenceDropdownEntries import com.illusionman1212.lyricsgrabbr.utils.annotatedStringResource
import com.illusionman1212.lyricsgrabbr.utils.mirror
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsState import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsState
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
import kotlinx.coroutines.runBlocking
private val Themes = hashMapOf( private val Themes = hashMapOf(
Theme.LIGHT.ordinal to R.string.theme_light, Theme.LIGHT.ordinal to R.string.theme_light,
@@ -62,10 +72,59 @@ private val Themes = hashMapOf(
Theme.SYSTEM.ordinal to R.string.theme_system 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 @Composable
fun SettingsPage( fun SettingsPage(
goBack: () -> Unit, goBack: () -> Unit,
navigateToWhitelist: () -> Unit,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -82,7 +141,8 @@ fun SettingsPage(
) { ) {
LGIconButton(tooltip = stringResource(id = R.string.go_back), onClick = goBack) { LGIconButton(tooltip = stringResource(id = R.string.go_back), onClick = goBack) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, modifier = Modifier.mirror(),
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource( contentDescription = stringResource(
id = R.string.go_back id = R.string.go_back
) ) ) )
@@ -99,17 +159,14 @@ fun SettingsPage(
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Appearance(viewModel, uiState) Appearance(viewModel, uiState)
geniURL(viewModel, uiState) Lyrics(viewModel, uiState)
Listening(navigateToWhitelist)
PowerManagement(viewModel, uiState)
} }
} }
} }
@Composable @Composable
private fun Appearance(viewModel: SettingsViewModel, uiState: SettingsState) { fun Appearance(viewModel: SettingsViewModel, uiState: SettingsState) {
var themeOpenDialog by remember { mutableStateOf(false) } var themeOpenDialog by remember { mutableStateOf(false) }
var languageOpenDialog by remember { mutableStateOf(false) }
SettingGroup(title = stringResource(id = R.string.appearance)) { SettingGroup(title = stringResource(id = R.string.appearance)) {
DialogSetting( DialogSetting(
@@ -118,17 +175,6 @@ private fun Appearance(viewModel: SettingsViewModel, uiState: SettingsState) {
icon = Icons.Outlined.Palette, icon = Icons.Outlined.Palette,
onClick = { themeOpenDialog = true } 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) { if (themeOpenDialog) {
ThemeDialog( ThemeDialog(
@@ -139,111 +185,6 @@ private 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
private fun PowerManagement(viewModel: SettingsViewModel, uiState: SettingsState) {
SettingGroup(title = stringResource(id = R.string.power_management)) {
ToggleSetting(
title = stringResource(id = R.string.keep_device_awake_title),
description = stringResource(id = R.string.keep_device_awake_desc),
icon = Icons.Default.ScreenLockPortrait,
checked = uiState.keepScreenOn
) {
viewModel.toggleKeepScreenOn()
}
}
}
@Composable
private fun geniURL(viewModel: SettingsViewModel, uiState: SettingsState) {
var dialogOpen by remember { mutableStateOf(false) }
SettingGroup(title = "geniURL") {
DialogSetting(
title = stringResource(id = R.string.api_base_url),
value = uiState.geniURLbaseURL,
icon = Icons.Outlined.Cloud,
onClick = { dialogOpen = true }
)
}
if (dialogOpen) {
BaseURLDialog(
uiState = uiState,
viewModel = viewModel,
onDismiss = { dialogOpen = false },
)
}
}
@Composable
private fun BaseURLDialog(uiState: SettingsState, viewModel: SettingsViewModel, onDismiss: () -> Unit) {
var value by remember { mutableStateOf(uiState.geniURLbaseURL) }
var isValidUrl by remember { mutableStateOf(true) }
LGAlertDialog(
onDismiss = onDismiss,
title = stringResource(id = R.string.base_url),
buttons = {
TextButton(
onClick = {
viewModel.setGeniURLBaseURL(value)
onDismiss()
},
enabled = isValidUrl
) {
Text(stringResource(id = R.string.confirm))
}
TextButton(onClick = onDismiss) {
Text(stringResource(id = R.string.cancel))
}
},
modifier = Modifier.fillMaxWidth()
) {
TextField(
value = value,
isError = !isValidUrl,
onValueChange = { newValue ->
isValidUrl = Patterns.WEB_URL.matcher(newValue.lowercase()).matches() && newValue.startsWith("http", true)
value = newValue
},
)
}
} }
@Composable @Composable
@@ -289,42 +230,79 @@ fun ThemeDialog(
} }
@Composable @Composable
fun LanguageDialog( fun Lyrics(viewModel: SettingsViewModel, uiState: SettingsState) {
onDismiss: () -> Unit, val context = LocalContext.current
) { val activity = context as Activity
val langs = LocalContext.current.getLangPreferenceDropdownEntries() val application = activity.application as LGApp
LGAlertDialog( var isDialogOpen by remember { mutableStateOf(false) }
onDismiss = onDismiss, var spotifyIdValue by remember { mutableStateOf("") }
title = stringResource(id = R.string.language),
buttons = { SettingGroup(stringResource(id = R.string.lyrics)) {
TextButton(onClick = onDismiss) { ToggleSetting(
Text(stringResource(id = R.string.cancel)) title = stringResource(id = R.string.use_spotify_api),
} checked = uiState.isSpotifyAuthed,
}, icon = painterResource(id = R.drawable.spotify),
modifier = Modifier.fillMaxWidth() onClick = {
) { if (uiState.isSpotifyAuthed) {
LazyColumn { application.credentialStore.clear()
item { viewModel.updateSpotifyAuthStatus(false)
LGRadioButton( } else {
text = stringResource(id = R.string._default), isDialogOpen = true
onClick = { }
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) },
}, )
selected = AppCompatDelegate.getApplicationLocales().isEmpty, }
)
} if (isDialogOpen) {
for ((key, value) in langs) { LGAlertDialog(
item { onDismiss = { isDialogOpen = false },
LGRadioButton( title = stringResource(id = R.string.spotify_client_id),
text = key, buttons = {
onClick = { TextButton(onClick = { isDialogOpen = false }) {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(value)) Text(stringResource(id = R.string.cancel))
}, }
selected = AppCompatDelegate.getApplicationLocales()[0]?.language == value, 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)
"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)
}
},
style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
)
TextField(
value = spotifyIdValue,
onValueChange = { spotifyIdValue = it },
placeholder = {
Text(stringResource(id = R.string.not_set))
},
visualTransformation = PasswordVisualTransformation(),
)
} }
} }
} }
@@ -339,7 +317,7 @@ fun SettingGroup(title: String, content: @Composable () -> Unit) {
} }
@Composable @Composable
private fun Header(title: String) { fun Header(title: String) {
Row(Modifier.padding(start = 56.dp)) { Row(Modifier.padding(start = 56.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.primary) Text(text = title, color = MaterialTheme.colorScheme.primary)
} }
@@ -387,8 +365,7 @@ fun DialogSetting(
@Composable @Composable
fun ToggleSetting( fun ToggleSetting(
title: String, title: String,
description: String? = null, icon: Painter? = null,
icon: ImageVector? = null,
checked: Boolean, checked: Boolean,
onClick: (Boolean) -> Unit) onClick: (Boolean) -> Unit)
{ {
@@ -403,20 +380,11 @@ fun ToggleSetting(
modifier = Modifier.padding(top = 8.dp, end = 24.dp, bottom = 8.dp), modifier = Modifier.padding(top = 8.dp, end = 24.dp, bottom = 8.dp),
) { ) {
if (icon != null) { if (icon != null) {
Icon(imageVector = icon, contentDescription = null, Modifier.width(56.dp)) Icon(painter = icon, contentDescription = null, Modifier.width(56.dp))
} else { } else {
Box(modifier = Modifier.width(56.dp)) Box(modifier = Modifier.width(56.dp))
} }
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(text = title, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1.0f))
Text(text = title, color = MaterialTheme.colorScheme.onBackground)
if (description != null) {
Text(
text = description,
color = ColorSecondaryLight,
fontSize = 14.sp,
)
}
}
Switch( Switch(
checked = checked, checked = checked,
onCheckedChange = null, onCheckedChange = null,
@@ -1,162 +0,0 @@
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,
)
}
}
}
@@ -0,0 +1,16 @@
package com.illusionman1212.lyricsgrabbr.utils
import android.util.LayoutDirection
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.core.text.layoutDirection
import java.util.Locale
@Stable
fun Modifier.mirror(): Modifier {
return if (Locale.getDefault().layoutDirection == LayoutDirection.RTL)
this.scale(scaleX = -1f, scaleY = 1f)
else
this
}
@@ -1,6 +1,5 @@
package com.illusionman1212.lyricsgrabbr.utils package com.illusionman1212.lyricsgrabbr.utils
import android.content.Context
import android.text.Annotation import android.text.Annotation
import android.text.SpannedString import android.text.SpannedString
import androidx.annotation.StringRes import androidx.annotation.StringRes
@@ -8,11 +7,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle 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 // Native stylization is coming soon
// https://issuetracker.google.com/issues/139320238 // https://issuetracker.google.com/issues/139320238
@@ -25,7 +19,7 @@ fun annotatedStringResource(
val spannedString = SpannedString(resources.getText(id)) val spannedString = SpannedString(resources.getText(id))
val resultBuilder = AnnotatedString.Builder() val resultBuilder = AnnotatedString.Builder()
resultBuilder.append(spannedString.toString()) resultBuilder.append(spannedString.toString())
spannedString.getSpans(0, spannedString.length, Annotation::class.java).forEach { annotation -> spannedString.getSpans<Annotation>(0, spannedString.length, Annotation::class.java).forEach { annotation ->
val spanStart = spannedString.getSpanStart(annotation) val spanStart = spannedString.getSpanStart(annotation)
val spanEnd = spannedString.getSpanEnd(annotation) val spanEnd = spannedString.getSpanEnd(annotation)
resultBuilder.addStringAnnotation( resultBuilder.addStringAnnotation(
@@ -38,37 +32,3 @@ fun annotatedStringResource(
} }
return resultBuilder.toAnnotatedString() 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
}
@@ -2,20 +2,21 @@ package com.illusionman1212.lyricsgrabbr.viewmodels
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.adamratzman.spotify.SpotifyClientApi
import com.illusionman1212.lyricsgrabbr.LGApp import com.illusionman1212.lyricsgrabbr.LGApp
import com.illusionman1212.lyricsgrabbr.NotificationEvent import com.illusionman1212.lyricsgrabbr.NotificationEvent
import com.illusionman1212.lyricsgrabbr.R
import com.illusionman1212.lyricsgrabbr.data.HomeRepository import com.illusionman1212.lyricsgrabbr.data.HomeRepository
import com.illusionman1212.lyricsgrabbr.data.SettingsPreferencesRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -87,7 +88,7 @@ data class HomeState(
val error: String? = null, val error: String? = null,
) )
class HomeViewModel(private val homeRepository: HomeRepository, private val settingsRepository: SettingsPreferencesRepository): ViewModel() { class HomeViewModel(private val context: Context, private val homeRepository: HomeRepository): ViewModel() {
private val _uiState = MutableStateFlow(HomeState()) private val _uiState = MutableStateFlow(HomeState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
private val json = Json { private val json = Json {
@@ -99,18 +100,41 @@ class HomeViewModel(private val homeRepository: HomeRepository, private val sett
} }
fun setSong(song: NotificationEvent) { fun setSong(song: NotificationEvent) {
_uiState.update { state -> _uiState.value = _uiState.value.copy(isLoading = true, notification = song, error = null)
state.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(context: Context, song: String, artist: String) { fun makeRequestToGenius(song: String, artist: String) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { HomeState(true, uiState.value.results, uiState.value.notification, null) } _uiState.update { HomeState(true, uiState.value.results, uiState.value.notification, null) }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val baseUrl = settingsRepository.preferences.first().geniURLbaseURL val (res, isSuccess) = homeRepository.makeSearchRequest(song, artist)
val (res, isSuccess) = homeRepository.makeSearchRequest(context, baseUrl, song, artist)
if (isSuccess) { if (isSuccess) {
val results = json.decodeFromString(SearchResultsResponse.serializer(), res!!).all.map { val results = json.decodeFromString(SearchResultsResponse.serializer(), res!!).all.map {
SearchResult( SearchResult(
@@ -139,7 +163,7 @@ class HomeViewModel(private val homeRepository: HomeRepository, private val sett
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
val application = (this[APPLICATION_KEY] as LGApp) val application = (this[APPLICATION_KEY] as LGApp)
HomeViewModel(application.homeRepository, application.settingsPreferencesRepository) HomeViewModel(application.applicationContext, application.homeRepository)
} }
} }
} }
@@ -22,6 +22,7 @@ data class LyricsState(
val artist: String = "", val artist: String = "",
val url: Uri = Uri.EMPTY, val url: Uri = Uri.EMPTY,
val trackId: String = "", val trackId: String = "",
val useSpotifyApi: Boolean = false,
// set by Lyrics screen // set by Lyrics screen
val lyrics: String = "", val lyrics: String = "",
@@ -34,23 +35,34 @@ class LyricsViewModel(private val lyricsRepository: LyricsRepository): ViewModel
private val _uiState = MutableStateFlow(LyricsState()) private val _uiState = MutableStateFlow(LyricsState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
fun onSongChange(trackId: String, title: String, subtitle: String, url: Uri) { fun onSongChange(trackId: String, title: String, subtitle: String, url: Uri, useSpotifyApi: Boolean) {
_uiState.update { _uiState.update {
it.copy(song = title, artist = subtitle, url = url, trackId = trackId) 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() { fun fetchLyricsFromGenius() {
_uiState.update { state -> _uiState.value = _uiState.value.copy(isLoading = true, lyrics = "")
state.copy(isLoading = true, lyrics = "")
}
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val lyrics = lyricsRepository.fetchLyricsFromGenius(uiState.value.url) val lyrics = lyricsRepository.fetchLyricsFromGenius(uiState.value.url)
_uiState.update { state -> _uiState.value = _uiState.value.copy(isLoading = false, lyrics = lyrics)
state.copy(isLoading = false, lyrics = lyrics)
}
} }
} }
} }
@@ -1,8 +1,5 @@
package com.illusionman1212.lyricsgrabbr.viewmodels 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.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -17,26 +14,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class AppInfo(
val displayName: String,
val icon: Drawable,
var isWhitelisted: Boolean,
)
data class SettingsState( data class SettingsState(
val appTheme: Int = Theme.SYSTEM.ordinal, val appTheme: Int = Theme.SYSTEM.ordinal,
val applications: Map<String, AppInfo> = emptyMap(), val isSpotifyAuthed: Boolean = false,
val whitelistSearchQuery: String = "",
val keepScreenOn: Boolean = false,
val geniURLbaseURL: String = "",
val whitelistPromptSeen: Boolean = true,
) )
class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesRepository, context: Context): ViewModel() { class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesRepository): ViewModel() {
private var immutablePackages: Map<String, AppInfo> = emptyMap()
private val _uiState = MutableStateFlow(SettingsState()) private val _uiState = MutableStateFlow(SettingsState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
@@ -50,134 +35,16 @@ class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesReposi
} }
} }
fun discardWhitelistPrompt() { fun updateSpotifyAuthStatus(isAuthed: Boolean) {
viewModelScope.launch(Dispatchers.IO) { _uiState.value = _uiState.value.copy(isSpotifyAuthed = isAuthed)
settingsPrefsRepo.discardWhitelistPrompt()
}
}
fun setGeniURLBaseURL(baseUrl: String) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setGeniURLBaseURL(baseUrl)
_uiState.update { state ->
state.copy(geniURLbaseURL = baseUrl)
}
}
}
fun toggleKeepScreenOn() {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.toggleKeepScreenOn()
}
}
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 { state ->
state.copy(
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 { init {
getPackages(context)
viewModelScope.launch { viewModelScope.launch {
settingsPrefsRepo.preferences.collectLatest { settingsPrefsRepo.preferences.collectLatest {
_uiState.update { state -> _uiState.value = _uiState.value.copy(
state.copy( appTheme = it.appTheme,
appTheme = it.appTheme, )
keepScreenOn = it.keepScreenOn,
whitelistPromptSeen = it.whitelistPromptSeen,
geniURLbaseURL = it.geniURLbaseURL,
)
}
} }
} }
} }
@@ -186,7 +53,7 @@ class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesReposi
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
val application = (this[APPLICATION_KEY] as LGApp) val application = (this[APPLICATION_KEY] as LGApp)
SettingsViewModel(application.settingsPreferencesRepository, application.applicationContext) SettingsViewModel(application.settingsPreferencesRepository)
} }
} }
} }
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.5,2 2,6.5 2,12s4.5,10 10,10s10,-4.5 10,-10S17.5,2 12,2zM16.057,16.406c-0.188,0 -0.282,-0.094 -0.47,-0.188c-1.411,-0.847 -3.198,-1.317 -5.079,-1.317c-1.035,0 -2.163,0.188 -3.104,0.376c-0.188,0 -0.376,0.094 -0.47,0.094c-0.376,0 -0.658,-0.282 -0.658,-0.658c0,-0.376 0.188,-0.658 0.564,-0.658c1.223,-0.282 2.446,-0.47 3.763,-0.47c2.163,0 4.139,0.564 5.832,1.505c0.188,0.188 0.376,0.282 0.376,0.658C16.621,16.218 16.339,16.406 16.057,16.406zM17.091,13.773c-0.188,0 -0.376,-0.094 -0.564,-0.188c-1.599,-1.035 -3.857,-1.599 -6.302,-1.599c-1.223,0 -2.352,0.188 -3.198,0.376c-0.188,0.094 -0.282,0.094 -0.47,0.094c-0.47,0 -0.753,-0.376 -0.753,-0.753c0,-0.47 0.188,-0.658 0.658,-0.847c1.129,-0.282 2.258,-0.564 3.857,-0.564c2.634,0 5.079,0.658 7.149,1.881c0.282,0.188 0.47,0.47 0.47,0.753C17.844,13.396 17.562,13.773 17.091,13.773zM18.314,10.762c-0.188,0 -0.282,-0.094 -0.564,-0.188c-1.881,-1.035 -4.609,-1.693 -7.243,-1.693c-1.317,0 -2.728,0.188 -3.951,0.47c-0.188,0 -0.282,0.094 -0.564,0.094C5.428,9.54 5.051,9.069 5.051,8.505c0,-0.564 0.282,-0.847 0.658,-0.941C7.215,7.188 8.814,7 10.507,7c2.916,0 5.926,0.658 8.278,1.881c0.282,0.188 0.564,0.47 0.564,0.941C19.255,10.386 18.878,10.762 18.314,10.762z"/>
</vector>
+7 -18
View File
@@ -8,10 +8,8 @@
<string name="confirm">تأكيد</string> <string name="confirm">تأكيد</string>
<string name="timeout">لقد انتهت مهلة الطلب، حاول مرة أخرى لاحقًا</string> <string name="timeout">لقد انتهت مهلة الطلب، حاول مرة أخرى لاحقًا</string>
<string name="generic_error">لقد حدث خطأ، حاول مرة أخرى لاحقاً</string> <string name="generic_error">لقد حدث خطأ، حاول مرة أخرى لاحقاً</string>
<string name="internal_server_error">تأكد من تشغيل خادم geniURL الخاص بك إذا كنت تستضيف ذاتيًا أو اتصل بمضيفك.</string>
<string name="invalid_json">الاستجابة لم تكن JSON صالحة</string>
<string name="failed_to_map_json">فشل تعيين JSON المفكك إلى النوع الداخلي</string>
<string name="retry">أعد المحاولة</string> <string name="retry">أعد المحاولة</string>
<string name="spotify_no_lyrics_error">عذرًا، لا يحتوي Spotify على كلمات لهذه الأغنية</string>
<!-- Home --> <!-- Home -->
<string name="no_supported_player">لا يوجد مشغل مدعوم قيد التشغيل</string> <string name="no_supported_player">لا يوجد مشغل مدعوم قيد التشغيل</string>
@@ -27,8 +25,7 @@
<string name="notification_post_permission_desc">يتطلب هذا التطبيق أذونات الإشعارات لبدء خدمة مقدمة بغرض الاستماع إلى إشعارات الموسيقى، ولا يمكن أن يعمل بدونها.</string> <string name="notification_post_permission_desc">يتطلب هذا التطبيق أذونات الإشعارات لبدء خدمة مقدمة بغرض الاستماع إلى إشعارات الموسيقى، ولا يمكن أن يعمل بدونها.</string>
<string name="notification_post_permission_not_granted">لم يتم منح إذن الإشعارات، لن يعمل التطبيق بشكل صحيح</string> <string name="notification_post_permission_not_granted">لم يتم منح إذن الإشعارات، لن يعمل التطبيق بشكل صحيح</string>
<string name="grant_permission">منح الإذن</string> <string name="grant_permission">منح الإذن</string>
<string name="understood">مفهوم</string> <string name="no_spotify_song_playing">No song is playing on Spotify</string>
<string name="whitelist_prompt_desc">يعمل التطبيق على أساس القائمة البيضاء. وهذا يمنحك التحكم في التطبيقات التي سنستمع إليها ويقلل أيضًا من التشويش على التطبيق.\nيمكنك التحكم في القائمة البيضاء من الإعدادات</string>
<!-- Settings --> <!-- Settings -->
<string name="settings">الإعدادات</string> <string name="settings">الإعدادات</string>
@@ -37,21 +34,13 @@
<string name="theme_light">نهار</string> <string name="theme_light">نهار</string>
<string name="theme_dark">ليلي</string> <string name="theme_dark">ليلي</string>
<string name="theme_system">استخدم مظهر النظام</string> <string name="theme_system">استخدم مظهر النظام</string>
<string name="language">اللغة</string> <string name="use_spotify_api">استخدم Spotify للكلمات</string>
<string name="_default">استخدم لغة النظام</string> <string name="spotify_client_id">Spotify Client ID</string>
<string name="listening">الإستماع</string> <string name="not_set">خالي</string>
<string name="whitelist_apps">تطبيقات القائمة البيضاء</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="whitelist_desc">اختر التطبيقات التي سيستمع التطبيق إلى إشعاراتها</string>
<string name="search">بحث</string>
<string name="clear">مسح</string>
<string name="power_management">إدارة إستخدام الطافة</string>
<string name="keep_device_awake_title">أبق الجهاز مستيقظا</string>
<string name="keep_device_awake_desc">أبق الجهاز مستيقظا على صفحة كلمات الأغاني (يزيد من استخدام البطارية)</string>
<string name="screen_will_stay_on">ستبقى الشاشة على قيد التشغيل</string>
<string name="api_base_url">عنوان URL الأساسي لواجهة برمجة التطبيقات (API)</string>
<string name="base_url">عنوان URL الأساسي</string>
<!-- Lyrics --> <!-- Lyrics -->
<string name="lyrics">كلمات الأغنية</string>
<string name="instrumental">هذه الأغنية موسيقية</string> <string name="instrumental">هذه الأغنية موسيقية</string>
<!-- Notification --> <!-- Notification -->
-54
View File
@@ -1,54 +0,0 @@
<resources>
<string name="app_name" translatable="false">Lyrics Grabbr</string>
<!-- General -->
<string name="go_back">Zurück</string>
<string name="cancel">Abbrechen</string>
<string name="timeout">Anfrage ist abgelaufen, bitte versuche es später erneut</string>
<string name="generic_error">Ein Fehler ist aufgetreten, bitte versuche es später erneut</string>
<string name="retry">Erneut versuchen</string>
<!-- Home -->
<string name="no_supported_player">Keinen unterstützten Player gefunden</string>
<string name="open_settings">Einstellungen öffnen</string>
<string name="notification_listen_permission">Berechtigung zum Auslesen von Benachrichtigungen</string>
<string name="notification_listen_desc">Diese App benötigt die Berechtigung zum Auslesen von Benachrichtigungen, um ordnungsgemäß zu funktionieren. Bitte erteile diese Berechtigung auf dem nächsten Bildschirm.\nBITTE BEACHTEN: der einzige Weg den Dienst zu stoppen ist die Berechtigung zum Auslesen von Benachrichtigungen zu widerrufen.</string>
<string name="no_results">Keine Ergebnisse gefunden</string>
<string name="found_results">%1$d Ergebnis(se) gefunden</string>
<string name="powered_by">Verwendet <annotation href="https://github.com/sv443/geniURL">geniURL</annotation></string>
<string name="open_song_page">Seite des Songs öffnen</string>
<string name="open_artist_page">Seite des Künstlers öffnen</string>
<string name="notification_post_permission">Berechtigung für Benachrichtigungen erforderlich</string>
<string name="notification_post_permission_desc">Lyrics Grabbr benötigt die Berechtigung Benachrichtigungen zu lesen, um einen Vordergrunddienst zum Abhören zu starten und kann nicht ohne diese funktionieren.</string>
<string name="notification_post_permission_not_granted">Berechtigung zum Abhören von Benachrichtigungen nicht erteilt, daher wird die App wird nicht ordnungsgemäß funktionieren.</string>
<string name="grant_permission">Berechtigung erteilen</string>
<!-- Settings -->
<string name="settings">Einstellungen</string>
<string name="appearance">Aussehen</string>
<string name="theme">Farbschema</string>
<string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string>
<string name="theme_system">Systemstandard</string>
<string name="language">Sprache</string>
<string name="_default">Systemsprache</string>
<string name="listening">Erlaubt</string>
<string name="whitelist_apps">Erlaubte Apps</string>
<string name="whitelist_desc">Wähle aus, welchen Apps Lyrics Grabbr zuhören soll</string>
<string name="search">Suche</string>
<string name="clear">Löschen</string>
<string name="power_management">Energieeinstellungen</string>
<string name="keep_device_awake_title">Gerät wachhalten</string>
<string name="keep_device_awake_desc">Gerät auf der Songtext-Seite wachhalten (erhöht den Batterieverbrauch)</string>
<string name="screen_will_stay_on">Bildschirm bleibt an</string>
<!-- Lyrics -->
<string name="instrumental">Dies ist ein instrumentaler Song</string>
<!-- Notification -->
<string name="service_running">Dienst läuft</string>
<string name="stop_service">Dienst anhalten</string>
<string name="service_notif_desc_init">Spiele Songs mit einem unterstützten Player, um Songtexte zu erhalten</string>
<string name="service_notif_desc_playing">Für den Songtext antippen</string>
<string name="now_playing">Es läuft %1$s von %2$s</string>
</resources>
+7 -18
View File
@@ -7,10 +7,8 @@
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>
<string name="timeout">Request has timed out, try again later</string> <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="generic_error">An error has occurred, try again later</string>
<string name="internal_server_error">Internal server error. Make sure your geniURL server is running if self-hosting or contact your hoster.</string>
<string name="invalid_json">Response was not valid JSON</string>
<string name="failed_to_map_json">Failed to map decoded JSON to internal type.</string>
<string name="retry">Retry</string> <string name="retry">Retry</string>
<string name="spotify_no_lyrics_error">Sorry, Spotify doesn\'t have lyrics for this song</string>
<!-- Home --> <!-- Home -->
<string name="no_supported_player">No supported player is running</string> <string name="no_supported_player">No supported player is running</string>
@@ -26,8 +24,7 @@
<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_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="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="grant_permission">Grant Permission</string>
<string name="understood">Understood</string> <string name="no_spotify_song_playing">No song is playing on Spotify</string>
<string name="whitelist_prompt_desc">The application operates on a whitelist basis. This gives you control over which apps we\'ll listen to and also reduces noise for the application.\nYou may control the whitelist from the settings.</string>
<!-- Settings --> <!-- Settings -->
<string name="settings">Settings</string> <string name="settings">Settings</string>
@@ -36,21 +33,13 @@
<string name="theme_light">Light</string> <string name="theme_light">Light</string>
<string name="theme_dark">Dark</string> <string name="theme_dark">Dark</string>
<string name="theme_system">Use System Theme</string> <string name="theme_system">Use System Theme</string>
<string name="language">Language</string> <string name="use_spotify_api">Use Spotify for lyrics</string>
<string name="_default">Use System Language</string> <string name="spotify_client_id" translatable="false">Spotify Client ID</string>
<string name="listening">Listening</string> <string name="not_set">Not Set</string>
<string name="whitelist_apps">Whitelist Apps</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="whitelist_desc">Choose which apps Lyrics Grabbr will listen to</string>
<string name="search">Search</string>
<string name="clear">Clear</string>
<string name="power_management">Power Management</string>
<string name="keep_device_awake_title">Keep device awake</string>
<string name="keep_device_awake_desc">Keep device awake on lyrics page (Increases battery usage)</string>
<string name="screen_will_stay_on">Screen Will Stay On</string>
<string name="api_base_url">API Base URL</string>
<string name="base_url">Base URL</string>
<!-- Lyrics --> <!-- Lyrics -->
<string name="lyrics">Lyrics</string>
<string name="instrumental">This song is an instrumental</string> <string name="instrumental">This song is an instrumental</string>
<!-- Notification --> <!-- Notification -->
-6
View File
@@ -1,6 +0,0 @@
<?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 android:name="de" />
</locale-config>
@@ -1,2 +0,0 @@
## Veröffentlichung auf F-droid
Initiale Veröffentlichung auf F-droid
@@ -1,2 +0,0 @@
- App mit Kotlin und Jetpack Compose neu geschrieben
- Möglichkeit hinzugefügt, Spotify für Songtexte zu verwenden
@@ -1,4 +0,0 @@
- Eine Whitelist von Apps ist jetzt erforderlich, damit die App Benachrichtigungen von ihnen abrufen kann
- Spotify wurde aufgrund des nicht-FOSS-SDKs vollständig entfernt
- Spracheinstellungen (Hilfe bei Übersetzungen ist sehr willkommen)
- Einstellung hinzugefügt, um das Gerät wach zu halten, während Songtexte angezeigt werden
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

@@ -1 +0,0 @@
Lyrics Grabbr bietet Songtexte für das aktuell abgespielte Lied anhand von Benachrichtigungen an
@@ -1 +0,0 @@
Lyrics Grabbr
@@ -1,4 +0,0 @@
- A whitelist of apps is now required in order for the app to pick up on notifications from them
- Completely remove Spotify due to non-FOSS SDK
- Language settings (help with translations is much appreciated)
- Added a setting to keep the device awake when reading lyrics
@@ -1,4 +0,0 @@
- Fix app crash when geniURL instance is down.
- Show a one-time notice related to whitelisting apps.
- Added a setting to allow for changing the geniURL instance.
- German translation
@@ -1,2 +0,0 @@
The app listens to your notifications and provides lyrics for the currently playing song.
It supports any player that uses the MediaSession API.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 145 KiB