rewrite the app in native kotlin with compose
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,107 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
kotlin("plugin.serialization") version "1.9.0"
|
||||
}
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile: File? = rootProject.file("keystore.properties")
|
||||
if (keystorePropertiesFile?.exists() == true) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.illusionman1212.lyricsgrabbr"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.illusionman1212.lyricsgrabbr"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 5
|
||||
versionName = "0.4.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = keystoreProperties["storeFile"]?.let { file(it)}
|
||||
storePassword = keystoreProperties["storePassword"] as String?
|
||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.1"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
excludes += "/META-INF/DEPENDENCIES"
|
||||
excludes += "mozilla/*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3:1.1.2")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("androidx.compose.material:material-icons-extended:1.5.4")
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
|
||||
implementation("io.coil-kt:coil:2.5.0")
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
implementation("it.skrape:skrapeit:1.2.2")
|
||||
implementation("org.jsoup:jsoup:1.17.2")
|
||||
implementation("com.adamratzman:spotify-api-kotlin-core:4.0.2")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.illusionman1212.lyricsgrabbr
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.illusionman1212.lyricsgrabbr", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<application
|
||||
android:name=".LGApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LyricsGrabbr"
|
||||
tools:targetApi="34">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.LyricsGrabbr.Splash"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".SpotifyPkceLoginActivityImpl"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="lyricsgrabbr" android:host="spotify-auth" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:label="Lyrics Grabbr Notifications Listener"
|
||||
android:name=".NotificationListener"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
<property
|
||||
android:name="android.app.NOTIFICATION_LISTENER"
|
||||
android:value="Media playback notification listener service" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,38 @@
|
||||
package com.illusionman1212.lyricsgrabbr
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.adamratzman.spotify.auth.SpotifyDefaultCredentialStore
|
||||
import com.illusionman1212.lyricsgrabbr.data.HomeRepository
|
||||
import com.illusionman1212.lyricsgrabbr.data.LyricsRepository
|
||||
import com.illusionman1212.lyricsgrabbr.data.SettingsPreferencesRepository
|
||||
|
||||
private const val PREFERENCES_NAME = "preferences"
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = PREFERENCES_NAME
|
||||
)
|
||||
|
||||
const val SPOTIFY_REDIRECT_URI = "lyricsgrabbr://spotify-auth"
|
||||
|
||||
class LGApp: Application() {
|
||||
lateinit var settingsPreferencesRepository: SettingsPreferencesRepository
|
||||
lateinit var homeRepository: HomeRepository
|
||||
lateinit var lyricsRepository: LyricsRepository
|
||||
val credentialStore by lazy {
|
||||
SpotifyDefaultCredentialStore(
|
||||
clientId = "10d81ce453e743559ea362df2e6a1fd2",
|
||||
redirectUri = SPOTIFY_REDIRECT_URI,
|
||||
applicationContext = applicationContext
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
settingsPreferencesRepository = SettingsPreferencesRepository(dataStore)
|
||||
homeRepository = HomeRepository(applicationContext)
|
||||
lyricsRepository = LyricsRepository(applicationContext)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.illusionman1212.lyricsgrabbr
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.illusionman1212.lyricsgrabbr.data.Theme
|
||||
import com.illusionman1212.lyricsgrabbr.ui.screens.HomePage
|
||||
import com.illusionman1212.lyricsgrabbr.ui.screens.LyricsPage
|
||||
import com.illusionman1212.lyricsgrabbr.ui.theme.LyricsGrabbrTheme
|
||||
import com.illusionman1212.lyricsgrabbr.ui.screens.SettingsPage
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
data class NotificationEvent(
|
||||
val packageName: String,
|
||||
val artist: String,
|
||||
val title: String,
|
||||
val playbackState: Int,
|
||||
val duration: Long
|
||||
)
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
var lastNotification: MutableStateFlow<NotificationEvent?> = MutableStateFlow(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
val context = LocalContext.current
|
||||
val activity = context as Activity
|
||||
val application = activity.application as LGApp
|
||||
|
||||
val settingsViewModel: SettingsViewModel = viewModel(
|
||||
factory = SettingsViewModel.Factory
|
||||
)
|
||||
val lyricsViewModel: LyricsViewModel = viewModel(
|
||||
factory = LyricsViewModel.Factory
|
||||
)
|
||||
|
||||
val appTheme = remember { mutableIntStateOf(Theme.SYSTEM.ordinal) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
LaunchedEffect(settingsViewModel.uiState) {
|
||||
settingsViewModel.uiState.collect { newState ->
|
||||
if (appTheme.intValue != newState.appTheme) {
|
||||
appTheme.intValue = newState.appTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
settingsViewModel.updateSpotifyAuthStatus(application.credentialStore.spotifyToken?.refreshToken != null)
|
||||
appTheme.intValue = settingsViewModel.getAppTheme()
|
||||
}
|
||||
|
||||
val darkTheme = when (appTheme.intValue) {
|
||||
Theme.LIGHT.ordinal -> false
|
||||
Theme.DARK.ordinal -> true
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
LyricsGrabbrTheme(darkTheme = darkTheme) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.Home.route,
|
||||
enterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { 1000 },
|
||||
animationSpec = tween(300)
|
||||
) + fadeIn(animationSpec = tween(300))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { -1000 },
|
||||
animationSpec = tween(300)
|
||||
) + fadeOut(animationSpec = tween(300))
|
||||
},
|
||||
) {
|
||||
composable(Routes.Home.route) {
|
||||
HomePage(
|
||||
navigateToSettings = {
|
||||
navController.navigate(Routes.Settings.route)
|
||||
},
|
||||
navigateToLyrics = {
|
||||
navController.navigate(Routes.Lyrics.route)
|
||||
},
|
||||
lyricsViewModel = lyricsViewModel,
|
||||
settingsViewModel = settingsViewModel,
|
||||
goBack = { finish() },
|
||||
lastNotification = lastNotification,
|
||||
)
|
||||
}
|
||||
composable(Routes.Lyrics.route) {
|
||||
LyricsPage(
|
||||
goBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
lyricsViewModel = lyricsViewModel,
|
||||
)
|
||||
}
|
||||
composable(Routes.Settings.route) {
|
||||
SettingsPage(
|
||||
goBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
viewModel = settingsViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
startListening()
|
||||
}
|
||||
|
||||
private fun isPermissionGranted(): Boolean {
|
||||
val packageName = packageName
|
||||
val pkgs = NotificationManagerCompat.getEnabledListenerPackages(applicationContext)
|
||||
|
||||
return pkgs.contains(packageName)
|
||||
}
|
||||
|
||||
private fun startListening() {
|
||||
val isServiceRunning = NotificationListener.isRunning.value
|
||||
|
||||
if (!isPermissionGranted() || isServiceRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
val listenerIntent = Intent(applicationContext, NotificationListener::class.java)
|
||||
listenerIntent.action = NotificationListener.START_FOREGROUND_SERVICE_ACTION
|
||||
|
||||
val filter = IntentFilter()
|
||||
filter.addAction(NotificationListener.NOTIFICATION_INTENT)
|
||||
LocalBroadcastManager.getInstance(applicationContext).registerReceiver(NotificationReceiver(), filter)
|
||||
ContextCompat.startForegroundService(applicationContext, listenerIntent)
|
||||
}
|
||||
|
||||
inner class NotificationReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val artist = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_ARTIST)
|
||||
val songTitle = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_SONG_TITLE)
|
||||
val playbackState = intent.getIntExtra(NotificationListener.NOTIFICATION_PACKAGE_PLAYBACK_STATE, -1)
|
||||
val duration = intent.getLongExtra(NotificationListener.NOTIFICATION_PACKAGE_DURATION, -1)
|
||||
val notifPackageName = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_NAME)
|
||||
|
||||
lastNotification.update { NotificationEvent(
|
||||
title = songTitle!!,
|
||||
artist = artist!!,
|
||||
playbackState = playbackState,
|
||||
duration = duration,
|
||||
packageName = notifPackageName!!,
|
||||
) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.illusionman1212.lyricsgrabbr
|
||||
|
||||
import android.Manifest.permission.POST_NOTIFICATIONS
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.media.MediaMetadata
|
||||
import android.media.session.MediaController
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Build
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class NotificationListener: NotificationListenerService() {
|
||||
private var notificationManager : NotificationManagerCompat? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
initNotificationChannels(this)
|
||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent != null) {
|
||||
when (intent.action) {
|
||||
START_FOREGROUND_SERVICE_ACTION -> {
|
||||
startForegroundService()
|
||||
getCurrentNotifications(applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onListenerConnected() {
|
||||
getCurrentNotifications(applicationContext)
|
||||
}
|
||||
|
||||
override fun onListenerDisconnected() {
|
||||
stopForegroundService()
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||
sendNotificationBroadcast(sbn, applicationContext)
|
||||
}
|
||||
|
||||
private fun getStopServiceNotificationAction(): PendingIntent {
|
||||
val intent = Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
return pendingIntent
|
||||
}
|
||||
|
||||
private fun startForegroundService() {
|
||||
_isRunning.update { true }
|
||||
|
||||
startForeground(NOTIFICATION_ID, getInitialNotification())
|
||||
}
|
||||
|
||||
private fun stopForegroundService() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
stopForeground(true)
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun initNotificationChannels(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
val notificationManager =
|
||||
context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
"Lyrics",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun getCurrentNotifications(context: Context) {
|
||||
val sbns = activeNotifications
|
||||
for (sbn in sbns) {
|
||||
sendNotificationBroadcast(sbn, context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInitialNotification(): Notification {
|
||||
val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(resources.getString(R.string.service_running))
|
||||
.setContentText(resources.getString(R.string.service_notif_desc_init))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setShowWhen(false)
|
||||
.addAction(R.drawable.ic_notification, resources.getString(R.string.stop_service), getStopServiceNotificationAction())
|
||||
.build()
|
||||
|
||||
notification.flags = Notification.FLAG_FOREGROUND_SERVICE
|
||||
|
||||
return notification
|
||||
}
|
||||
|
||||
private fun sendNotificationBroadcast(sbn: StatusBarNotification, context: Context) {
|
||||
if (ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
// Apparently the new getParcelable API is buggy and throws an exception if used.
|
||||
// so we continue to use the deprecated API even on newer versions.
|
||||
// https://issuetracker.google.com/issues/240585930
|
||||
val mediaSessionToken = sbn.notification.extras.getParcelable<MediaSession.Token>(Notification.EXTRA_MEDIA_SESSION)
|
||||
?: return
|
||||
|
||||
val mediaCtrl = MediaController(context, mediaSessionToken)
|
||||
val duration = mediaCtrl.metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.div(1000)
|
||||
val playbackState = mediaCtrl.playbackState?.state
|
||||
val albumArt = mediaCtrl.metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
|
||||
|
||||
// Retrieve extra object from notification to extract payload.
|
||||
val extras = sbn.notification.extras
|
||||
val packageArtist = extras?.getCharSequence(Notification.EXTRA_TEXT).toString()
|
||||
val packageSongTitle = extras?.getCharSequence("android.title").toString()
|
||||
// Pass data from one activity to another.
|
||||
val intent = Intent(NOTIFICATION_INTENT)
|
||||
intent.putExtra(NOTIFICATION_PACKAGE_NAME, sbn.packageName)
|
||||
intent.putExtra(NOTIFICATION_PACKAGE_ARTIST, packageArtist)
|
||||
intent.putExtra(NOTIFICATION_PACKAGE_SONG_TITLE, packageSongTitle)
|
||||
intent.putExtra(NOTIFICATION_PACKAGE_DURATION, duration)
|
||||
intent.putExtra(NOTIFICATION_PACKAGE_PLAYBACK_STATE, playbackState)
|
||||
|
||||
if (playbackState == PlaybackState.STATE_PAUSED) {
|
||||
notificationManager?.notify(NOTIFICATION_ID, getInitialNotification())
|
||||
} else {
|
||||
val notificationIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(resources.getString(R.string.now_playing, "[${packageSongTitle}]", "[${packageArtist}]"))
|
||||
.setContentText(resources.getString(R.string.service_notif_desc_playing))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setLargeIcon(albumArt)
|
||||
.setShowWhen(false)
|
||||
.setContentIntent(pendingIntent)
|
||||
.addAction(R.drawable.ic_notification, resources.getString(R.string.stop_service), getStopServiceNotificationAction())
|
||||
.build()
|
||||
|
||||
notification.flags = Notification.FLAG_FOREGROUND_SERVICE
|
||||
notificationManager?.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val _isRunning = MutableStateFlow(false)
|
||||
val isRunning = _isRunning.asStateFlow()
|
||||
|
||||
const val NOTIFICATION_INTENT = "notification_event"
|
||||
const val NOTIFICATION_PACKAGE_NAME = "package_name"
|
||||
const val NOTIFICATION_PACKAGE_ARTIST = "package_artist"
|
||||
const val NOTIFICATION_PACKAGE_SONG_TITLE = "package_song_title"
|
||||
const val NOTIFICATION_PACKAGE_PLAYBACK_STATE = "package_playback_state"
|
||||
const val NOTIFICATION_PACKAGE_DURATION = "package_duration"
|
||||
const val NOTIFICATION_ID = 1
|
||||
const val NOTIFICATION_CHANNEL_ID = "Lyrics"
|
||||
const val START_FOREGROUND_SERVICE_ACTION = "START_SERVICE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.illusionman1212.lyricsgrabbr
|
||||
|
||||
sealed class Routes(val route: String) {
|
||||
object Home : Routes("home")
|
||||
object Lyrics : Routes("lyrics")
|
||||
object Settings : Routes("settings")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.illusionman1212.lyricsgrabbr
|
||||
|
||||
import android.widget.Toast
|
||||
import com.adamratzman.spotify.SpotifyClientApi
|
||||
import com.adamratzman.spotify.SpotifyScope
|
||||
import com.adamratzman.spotify.auth.pkce.AbstractSpotifyPkceLoginActivity
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
|
||||
|
||||
class SpotifyPkceLoginActivityImpl: AbstractSpotifyPkceLoginActivity() {
|
||||
override val clientId: String = CLIENT_ID
|
||||
override val redirectUri: String = SPOTIFY_REDIRECT_URI
|
||||
override val scopes: List<SpotifyScope> = listOf(SpotifyScope.UserReadCurrentlyPlaying)
|
||||
|
||||
override fun onFailure(exception: Exception) {
|
||||
Toast.makeText(this, "Failed to authenticate", Toast.LENGTH_SHORT).show()
|
||||
settingsViewModel?.updateSpotifyAuthStatus(false)
|
||||
}
|
||||
|
||||
override fun onSuccess(api: SpotifyClientApi) {
|
||||
(application as LGApp).credentialStore.setSpotifyApi(api)
|
||||
Toast.makeText(this, "Successfully authenticated", Toast.LENGTH_SHORT).show()
|
||||
settingsViewModel?.updateSpotifyAuthStatus(true)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var CLIENT_ID = ""
|
||||
var settingsViewModel: SettingsViewModel? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.illusionman1212.lyricsgrabbr.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.illusionman1212.lyricsgrabbr.R
|
||||
import com.illusionman1212.lyricsgrabbr.data.network.GET
|
||||
import com.illusionman1212.lyricsgrabbr.data.network.NetworkHelper
|
||||
import com.illusionman1212.lyricsgrabbr.utils.await
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okio.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
@Serializable
|
||||
data class ErrorResponse(
|
||||
val error: Boolean,
|
||||
val matches: Int?,
|
||||
)
|
||||
|
||||
class HomeRepository(private val context: Context) {
|
||||
private val networkHelper = NetworkHelper(context)
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun isPermissionGranted(): Boolean {
|
||||
val packageName = context.packageName
|
||||
val pkgs = NotificationManagerCompat.getEnabledListenerPackages(context)
|
||||
|
||||
return pkgs.contains(packageName)
|
||||
}
|
||||
|
||||
suspend fun makeSearchRequest(song: String, artist: String): Pair<String?, Boolean> {
|
||||
val builder = Uri.Builder()
|
||||
val url = builder.scheme("https")
|
||||
.authority("api.illusionman1212.com")
|
||||
.appendPath("lyrics")
|
||||
.appendPath("search")
|
||||
.appendQueryParameter("q", "$song $artist")
|
||||
.appendQueryParameter("disableFuzzy", "true")
|
||||
.build()
|
||||
|
||||
try {
|
||||
val res = networkHelper.client.newCall(
|
||||
GET(url.toString())
|
||||
).await()
|
||||
|
||||
if (!res.isSuccessful) {
|
||||
val errRes = json.decodeFromString(ErrorResponse.serializer(), res.body!!.string())
|
||||
|
||||
// matches is only null if there's an error. otherwise it's 0 when there are no results
|
||||
if (errRes.matches == null) {
|
||||
return Pair(context.resources.getString(R.string.generic_error), false)
|
||||
}
|
||||
|
||||
return Pair(null, false)
|
||||
}
|
||||
|
||||
return Pair(res.body?.string(), true)
|
||||
} catch (e: IOException) {
|
||||
if (e.cause is SocketTimeoutException) {
|
||||
return Pair(context.resources.getString(R.string.timeout), false)
|
||||
}
|
||||
|
||||
return Pair(context.resources.getString(R.string.generic_error), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.illusionman1212.lyricsgrabbr.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.illusionman1212.lyricsgrabbr.R
|
||||
import com.illusionman1212.lyricsgrabbr.data.network.GET
|
||||
import com.illusionman1212.lyricsgrabbr.data.network.NetworkHelper
|
||||
import com.illusionman1212.lyricsgrabbr.utils.await
|
||||
import it.skrape.core.htmlDocument
|
||||
import it.skrape.selects.ElementNotFoundException
|
||||
import it.skrape.selects.html5.div
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.internal.StringUtil
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.nodes.Node
|
||||
import org.jsoup.nodes.TextNode
|
||||
import org.jsoup.select.NodeVisitor
|
||||
|
||||
@Serializable
|
||||
data class LyricLine(
|
||||
val startTimeMs: ULong,
|
||||
val words: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Lyrics(
|
||||
val syncType: String,
|
||||
val lines: List<LyricLine>,
|
||||
val language: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SuccessResponse(
|
||||
val lyrics: Lyrics,
|
||||
)
|
||||
|
||||
class LyricsRepository(private val context: Context) {
|
||||
private val networkHelper = NetworkHelper(context)
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
suspend fun fetchLyricsFromSpotify(trackId: String): Pair<String, Boolean> {
|
||||
val builder = Uri.Builder()
|
||||
val url = builder.scheme("https")
|
||||
.authority("api.lyricstify.vercel.app")
|
||||
.appendPath("v1")
|
||||
.appendPath("lyrics")
|
||||
.appendPath(trackId)
|
||||
.build()
|
||||
|
||||
val res = networkHelper.client.newCall(
|
||||
GET(url.toString())
|
||||
).await()
|
||||
|
||||
if (res.isSuccessful) {
|
||||
return Pair(json.decodeFromString(SuccessResponse.serializer(), res.body!!.string())
|
||||
.lyrics
|
||||
.lines.joinToString(separator = "\n") { it.words }, true)
|
||||
} else {
|
||||
if (res.code == 404) {
|
||||
return Pair(context.resources.getString(R.string.spotify_no_lyrics_error), false)
|
||||
}
|
||||
|
||||
return Pair(context.resources.getString(R.string.generic_error), false)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchLyricsFromGenius(url: Uri): String {
|
||||
// TODO: write a custom fetcher for skrape.it to use our OkHttp client
|
||||
// and get rid of jsoup. Or better yet, maybe skrape.it will be fixed in the future
|
||||
// and we can just use its built in BrowserFetcher
|
||||
val doc = Jsoup.connect(url.toString()).userAgent(USER_AGENT).get()
|
||||
|
||||
try {
|
||||
val lyrics = htmlDocument(doc.toString()) {
|
||||
// set relaxed for instrumental songs
|
||||
relaxed = true
|
||||
|
||||
findFirst {
|
||||
div {
|
||||
withAttributeKey = "data-lyrics-container"
|
||||
findAll {
|
||||
this
|
||||
}.joinToString(separator = "\n") {
|
||||
val accum = StringUtil.borrowBuilder()
|
||||
it.element.text()
|
||||
|
||||
it.element.traverse(object : NodeVisitor {
|
||||
override fun head(node: Node, depth: Int) {
|
||||
if (node is TextNode) {
|
||||
accum.append(node.text().trim())
|
||||
// append space here to get proper spacing when there's a <i> tag or other inline text tags
|
||||
accum.append(" ")
|
||||
} else if (node is Element) {
|
||||
if (accum.isNotEmpty() && node.normalName() == "br") {
|
||||
accum.append("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun tail(node: Node, depth: Int) {
|
||||
// since we append a space after every text node, we only append a newline if
|
||||
// the last char isn't a space
|
||||
val lastCharIsWhitespace = accum.isNotEmpty() && accum[accum.length - 1] == ' '
|
||||
if (node is Element) {
|
||||
if (node.isBlock && node.nextSibling() is TextNode && !lastCharIsWhitespace) {
|
||||
accum.append("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
accum
|
||||
}
|
||||
}
|
||||
}.replace("\n\n\n", "\n\n")
|
||||
}
|
||||
|
||||
return lyrics
|
||||
} catch (e: ElementNotFoundException) {
|
||||
// TODO: relaxed is broken in skrape.it, so we have to catch this exception
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.illusionman1212.lyricsgrabbr.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class Theme {
|
||||
LIGHT,
|
||||
DARK,
|
||||
SYSTEM,
|
||||
}
|
||||
|
||||
data class SettingsPreferences(
|
||||
val appTheme: Int = Theme.SYSTEM.ordinal,
|
||||
)
|
||||
|
||||
class SettingsPreferencesRepository(
|
||||
private val dataStore: DataStore<Preferences>
|
||||
) {
|
||||
companion object {
|
||||
private val APP_THEME = intPreferencesKey("app_theme")
|
||||
}
|
||||
|
||||
val preferences = dataStore.data.map { mapSettingsPreferences(it) }
|
||||
|
||||
suspend fun setTheme(theme: Theme) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[APP_THEME] = theme.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapSettingsPreferences(prefs: Preferences): SettingsPreferences {
|
||||
return SettingsPreferences(
|
||||
prefs[APP_THEME] ?: Theme.SYSTEM.ordinal,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.illusionman1212.lyricsgrabbr.data.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class NetworkHelper(context: Context) {
|
||||
private val cacheDir = File(context.cacheDir, "network_cache")
|
||||
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
|
||||
|
||||
private val baseClientBuilder: OkHttpClient.Builder
|
||||
get() {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(2, TimeUnit.MINUTES)
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.illusionman1212.lyricsgrabbr.data.network
|
||||
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, TimeUnit.MINUTES).build()
|
||||
private val DEFAULT_HEADERS = Headers.Builder().build()
|
||||
|
||||
fun GET(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return GET(url.toHttpUrl(), headers, cache)
|
||||
}
|
||||
|
||||
fun GET(
|
||||
url: HttpUrl,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.components
|
||||
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.withSave
|
||||
|
||||
const val DefaultAlpha = 1f
|
||||
|
||||
fun forwardingPainter(
|
||||
painter: Painter,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
size: Size,
|
||||
onDraw: DrawScope.(ForwardingDrawInfo) -> Unit = DefaultOnDraw,
|
||||
): Painter = ForwardingPainter(painter, alpha, colorFilter, size, onDraw)
|
||||
|
||||
data class ForwardingDrawInfo(
|
||||
val painter: Painter,
|
||||
val alpha: Float,
|
||||
val colorFilter: ColorFilter?,
|
||||
val size: Size,
|
||||
)
|
||||
|
||||
private class ForwardingPainter(
|
||||
private val painter: Painter,
|
||||
private var alpha: Float,
|
||||
private var colorFilter: ColorFilter?,
|
||||
private val size: Size,
|
||||
private val onDraw: DrawScope.(ForwardingDrawInfo) -> Unit,
|
||||
) : Painter() {
|
||||
|
||||
private var info = newInfo()
|
||||
|
||||
override val intrinsicSize get() = painter.intrinsicSize
|
||||
|
||||
override fun applyAlpha(alpha: Float): Boolean {
|
||||
if (alpha != DefaultAlpha) {
|
||||
this.alpha = alpha
|
||||
this.info = newInfo()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
|
||||
if (colorFilter != null) {
|
||||
this.colorFilter = colorFilter
|
||||
this.info = newInfo()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun DrawScope.onDraw() = onDraw(info)
|
||||
|
||||
private fun newInfo() = ForwardingDrawInfo(painter, alpha, colorFilter, size)
|
||||
}
|
||||
|
||||
private val DefaultOnDraw: DrawScope.(ForwardingDrawInfo) -> Unit = { info ->
|
||||
val size = info.size
|
||||
|
||||
drawIntoCanvas {
|
||||
val width = it.nativeCanvas.width.toFloat()
|
||||
val height = it.nativeCanvas.height.toFloat()
|
||||
val left = (width - size.width) / 2f
|
||||
val top = (height - size.height) / 2f
|
||||
|
||||
it.withSave {
|
||||
it.translate(left, top)
|
||||
with(info.painter) {
|
||||
draw(size, info.alpha, info.colorFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.illusionman1212.lyricsgrabbr.ui.theme.Typography
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LGAlertDialog(
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
properties: DialogProperties = DialogProperties(),
|
||||
title: String,
|
||||
buttons: @Composable () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = properties,
|
||||
) {
|
||||
Surface (
|
||||
modifier = Modifier.wrapContentHeight().wrapContentWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = AlertDialogDefaults.TonalElevation
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(text = title, style = Typography.titleLarge)
|
||||
content()
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.PlainTooltipBox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LGIconButton(
|
||||
tooltip: String,
|
||||
onClick: () -> Unit,
|
||||
badge: (@Composable BoxScope.() -> Unit)? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Layout(
|
||||
content = {
|
||||
PlainTooltipBox(
|
||||
tooltip = {
|
||||
Text(text = tooltip)
|
||||
},
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
Modifier.tooltipAnchor(),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
Box(Modifier.layoutId("badge")) {
|
||||
badge?.let { it() }
|
||||
}
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
val iconPlaceable = measurables[0].measure(constraints)
|
||||
val badgePlaceable = measurables.firstOrNull { it.layoutId == "badge" }?.measure(constraints)
|
||||
|
||||
layout(iconPlaceable.width, iconPlaceable.height) {
|
||||
iconPlaceable.placeRelative(0, 0)
|
||||
badgePlaceable?.placeRelative(
|
||||
x = iconPlaceable.width - badgePlaceable.width - 16,
|
||||
y = 16,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PlainTooltipBox(tooltip = {
|
||||
// Text(text = tooltip)
|
||||
// }) {
|
||||
// if (badge != null) {
|
||||
// IconButton(
|
||||
// onClick = onClick,
|
||||
// Modifier.tooltipAnchor(),
|
||||
// ) {
|
||||
// BadgedBox(badge = badge) {
|
||||
// content()
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// IconButton(
|
||||
// onClick = onClick,
|
||||
// Modifier.tooltipAnchor(),
|
||||
// ) {
|
||||
// content()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Composable
|
||||
fun LGRadioButton(text: String, onClick: () -> Unit, selected: Boolean) {
|
||||
Box (Modifier.clickable { onClick() }.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun LGRadioButtonPreview() {
|
||||
LGRadioButton(text = "Radio Button", onClick = {}, selected = true)
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.screens
|
||||
|
||||
import android.Manifest.permission.POST_NOTIFICATIONS
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BrokenImage
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.LibraryMusic
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.compose.currentStateAsState
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.illusionman1212.lyricsgrabbr.NotificationEvent
|
||||
import com.illusionman1212.lyricsgrabbr.R
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.LGAlertDialog
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.HomeViewModel
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.SearchResult
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import coil.compose.AsyncImage
|
||||
import com.illusionman1212.lyricsgrabbr.LGApp
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.forwardingPainter
|
||||
import com.illusionman1212.lyricsgrabbr.utils.annotatedStringResource
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomePage(
|
||||
navigateToSettings: () -> Unit,
|
||||
navigateToLyrics: () -> Unit,
|
||||
goBack: () -> Unit,
|
||||
homeViewModel: HomeViewModel = viewModel(
|
||||
factory = HomeViewModel.Factory,
|
||||
),
|
||||
settingsViewModel: SettingsViewModel,
|
||||
lyricsViewModel: LyricsViewModel,
|
||||
lastNotification: MutableStateFlow<NotificationEvent?>
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val application = (context as Activity).application as LGApp
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
var artistUri by remember { mutableStateOf(Uri.EMPTY) }
|
||||
var songUri by remember { mutableStateOf(Uri.EMPTY) }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
var listeningPermission = homeViewModel.getListeningPermission()
|
||||
var notificationPermission by remember { mutableStateOf(
|
||||
ActivityCompat.checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED
|
||||
) }
|
||||
var shouldShowNotificationPermissionRationale by remember { mutableStateOf(
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
context as android.app.Activity,
|
||||
POST_NOTIFICATIONS
|
||||
)
|
||||
)}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
notificationPermission = isGranted
|
||||
|
||||
// we hide the rationale after launching once because launching again doesn't seem work
|
||||
shouldShowNotificationPermissionRationale = false
|
||||
}
|
||||
|
||||
val song = lastNotification.collectAsStateWithLifecycle().value
|
||||
val uiState = homeViewModel.uiState.collectAsStateWithLifecycle().value
|
||||
var getLyricsFromSpotify by remember { mutableStateOf(false)}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!shouldShowNotificationPermissionRationale)
|
||||
launcher.launch(POST_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
LaunchedEffect(lifecycleState) {
|
||||
when (lifecycleState) {
|
||||
Lifecycle.State.RESUMED -> {
|
||||
listeningPermission = homeViewModel.getListeningPermission()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(song) {
|
||||
if (song != null && song.title != uiState.notification?.title && song.artist != uiState.notification?.artist) {
|
||||
val spotifyApi = application.credentialStore.getSpotifyClientPkceApi()
|
||||
val useSpotifyApi = song.packageName == "com.spotify.music" && spotifyApi != null
|
||||
getLyricsFromSpotify = useSpotifyApi
|
||||
|
||||
homeViewModel.setSong(song)
|
||||
|
||||
if (useSpotifyApi) {
|
||||
homeViewModel.makeRequestToSpotify(spotifyApi!!)
|
||||
} else {
|
||||
homeViewModel.makeRequestToGenius(song.title, song.artist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text(text = stringResource(id = R.string.app_name)) },
|
||||
actions = {
|
||||
LGIconButton(
|
||||
tooltip = stringResource(id = R.string.settings),
|
||||
onClick = navigateToSettings,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Settings,
|
||||
contentDescription = stringResource(id = R.string.settings)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.padding(top = 16.dp)
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (!notificationPermission && shouldShowNotificationPermissionRationale) {
|
||||
LGAlertDialog(
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
),
|
||||
onDismiss = goBack,
|
||||
title = stringResource(id = R.string.notification_post_permission),
|
||||
buttons = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
launcher.launch(POST_NOTIFICATIONS)
|
||||
}
|
||||
) {
|
||||
Text(stringResource(id = R.string.grant_permission))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.notification_post_permission_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!listeningPermission && notificationPermission) {
|
||||
LGAlertDialog(
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
),
|
||||
onDismiss = goBack,
|
||||
title = stringResource(id = R.string.notification_listen_permission),
|
||||
buttons = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
android.content.Intent(
|
||||
android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(stringResource(id = R.string.open_settings))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.notification_listen_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (song == null) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text( text = stringResource(id = R.string.no_supported_player), style = MaterialTheme.typography.headlineSmall )
|
||||
if (!notificationPermission) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.notification_post_permission_not_granted).uppercase(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
} else {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(36.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = song.title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = song.artist,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
if (uiState.error != null) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = uiState.error,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
fontStyle = FontStyle.Italic,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
homeViewModel.makeRequestToGenius(song.title, song.artist)
|
||||
}
|
||||
) {
|
||||
Text(stringResource(id = R.string.retry))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Results(
|
||||
uiState.results,
|
||||
showBottomSheet = { artistUrl, songUrl ->
|
||||
showBottomSheet = !(artistUrl == Uri.EMPTY && songUrl == Uri.EMPTY)
|
||||
artistUri = artistUrl
|
||||
songUri = songUrl
|
||||
},
|
||||
navigateToLyrics = navigateToLyrics,
|
||||
setLyricsTitle = { id, title, subtitle, url ->
|
||||
lyricsViewModel.onSongChange(id, title, subtitle, url, getLyricsFromSpotify)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier.height(36.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
val linkColor = MaterialTheme.colorScheme.surfaceTint
|
||||
val text = annotatedStringResource(id = R.string.powered_by) {
|
||||
when (it.key) {
|
||||
"href" -> SpanStyle(color = linkColor)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
val textColor = LocalContentColor.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
ClickableText(
|
||||
text,
|
||||
onClick = { offset ->
|
||||
text.getStringAnnotations(tag = "href", start = offset, end = offset)
|
||||
.firstOrNull()
|
||||
?.let {
|
||||
uriHandler.openUri(it.item)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium.copy(color = textColor, fontStyle = FontStyle.Italic),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
LongClickBottomSheet(
|
||||
onDismiss = {
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
showBottomSheet = false
|
||||
}
|
||||
},
|
||||
sheetState = sheetState,
|
||||
artistUri = artistUri,
|
||||
songUri = songUri,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Results(
|
||||
results: List<SearchResult>,
|
||||
navigateToLyrics: () -> Unit,
|
||||
setLyricsTitle: (id: String, title: String, subtitle: String, url: Uri) -> Unit,
|
||||
showBottomSheet: (artistUrl: Uri, songUrl: Uri) -> Unit,
|
||||
) {
|
||||
if (results.isEmpty()) {
|
||||
Text(stringResource(id = R.string.no_results), style = MaterialTheme.typography.titleLarge)
|
||||
} else {
|
||||
Column(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.found_results, results.size),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
LazyColumn {
|
||||
items(results) {
|
||||
ResultListItem(
|
||||
it,
|
||||
onClick = {
|
||||
setLyricsTitle(it.id, it.title, it.artist, it.url)
|
||||
navigateToLyrics()
|
||||
},
|
||||
onLongClick = {
|
||||
showBottomSheet(it.artistUrl, it.url)
|
||||
}
|
||||
)
|
||||
|
||||
if (it != results.last())
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LongClickBottomSheet(
|
||||
onDismiss: () -> Unit,
|
||||
sheetState: SheetState,
|
||||
artistUri: Uri,
|
||||
songUri: Uri,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
ModalBottomSheet(
|
||||
dragHandle = {},
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
windowInsets = WindowInsets(top = 0.dp),
|
||||
) {
|
||||
Column {
|
||||
if (artistUri != Uri.EMPTY) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { uriHandler.openUri(artistUri.toString()) }
|
||||
.padding(horizontal = 24.dp, vertical = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Filled.LibraryMusic, contentDescription = "Music Library")
|
||||
Text(stringResource(id = R.string.open_artist_page), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
if (songUri != Uri.EMPTY) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { uriHandler.openUri(songUri.toString()) }
|
||||
.padding(horizontal = 24.dp, vertical = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Filled.MusicNote, contentDescription = "Music Note")
|
||||
Text(stringResource(id = R.string.open_song_page), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ResultListItem(
|
||||
item: SearchResult,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
onLongClick()
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
},
|
||||
)
|
||||
) {
|
||||
val color = LocalContentColor.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
AsyncImage(
|
||||
item.thumbnail,
|
||||
error = forwardingPainter(
|
||||
rememberVectorPainter(image = Icons.Filled.BrokenImage),
|
||||
colorFilter = ColorFilter.tint(color),
|
||||
size = with(density) { DpSize(40.dp, 40.dp).toSize() },
|
||||
),
|
||||
fallback = rememberVectorPainter(Icons.Filled.Image),
|
||||
contentDescription = "Thumbnail for ${item.title}",
|
||||
modifier = Modifier.size(75.dp),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
item.title,
|
||||
fontSize = TextUnit(18F, TextUnitType.Sp),
|
||||
fontWeight = FontWeight.Black,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
item.artist,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun ResultListItemPreview() {
|
||||
ResultListItem(
|
||||
item = SearchResult(
|
||||
"Test",
|
||||
"Artist",
|
||||
id = "1",
|
||||
url = Uri.EMPTY,
|
||||
artistUrl = Uri.EMPTY,
|
||||
thumbnail = Uri.EMPTY
|
||||
),
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.illusionman1212.lyricsgrabbr.R
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.LyricsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LyricsPage(
|
||||
goBack: () -> Unit,
|
||||
lyricsViewModel: LyricsViewModel = viewModel(
|
||||
factory = LyricsViewModel.Factory
|
||||
),
|
||||
) {
|
||||
val uiState = lyricsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||
|
||||
LaunchedEffect(uiState.song, uiState.artist, uiState.url) {
|
||||
if (uiState.useSpotifyApi) {
|
||||
lyricsViewModel.fetchLyricsFromSpotify()
|
||||
} else {
|
||||
lyricsViewModel.fetchLyricsFromGenius()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
uiState.song,
|
||||
fontWeight = FontWeight.Black,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
uiState.artist,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
LGIconButton(
|
||||
tooltip = stringResource(id = R.string.go_back),
|
||||
onClick = goBack
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(
|
||||
id = R.string.go_back
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.then(
|
||||
if (!uiState.isLoading && uiState.lyrics.isNotEmpty()) Modifier.verticalScroll(
|
||||
rememberScrollState()
|
||||
) else Modifier
|
||||
),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
if (uiState.lyrics.isEmpty()) {
|
||||
Instrumental()
|
||||
} else {
|
||||
Column(Modifier.padding(top = 12.dp, start = 12.dp, end = 12.dp, bottom = 32.dp)) {
|
||||
SelectionContainer {
|
||||
TextField(
|
||||
value = uiState.lyrics,
|
||||
onValueChange = {},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
readOnly = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun Instrumental() {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.MusicNote,
|
||||
contentDescription = "Music Note",
|
||||
modifier = Modifier.size(50.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(id = R.string.instrumental),
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.Palette
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.adamratzman.spotify.SpotifyClientApi
|
||||
import com.adamratzman.spotify.SpotifyException
|
||||
import com.adamratzman.spotify.auth.pkce.startSpotifyClientPkceLoginActivity
|
||||
import com.illusionman1212.lyricsgrabbr.LGApp
|
||||
import com.illusionman1212.lyricsgrabbr.R
|
||||
import com.illusionman1212.lyricsgrabbr.SpotifyPkceLoginActivityImpl
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.LGAlertDialog
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.LGRadioButton
|
||||
import com.illusionman1212.lyricsgrabbr.ui.theme.ColorSecondaryLight
|
||||
import com.illusionman1212.lyricsgrabbr.data.Theme
|
||||
import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton
|
||||
import com.illusionman1212.lyricsgrabbr.utils.annotatedStringResource
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsState
|
||||
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
private val Themes = hashMapOf(
|
||||
Theme.LIGHT.ordinal to R.string.theme_light,
|
||||
Theme.DARK.ordinal to R.string.theme_dark,
|
||||
Theme.SYSTEM.ordinal to R.string.theme_system
|
||||
)
|
||||
|
||||
// TODO: this is here as a reference because I think I will need a guard in the future
|
||||
fun <T> Activity.guardValidSpotifyApi(
|
||||
classBackTo: Class<out Activity>,
|
||||
alreadyTriedToReauthenticate: Boolean = false,
|
||||
block: suspend (api: SpotifyClientApi) -> T
|
||||
): T? {
|
||||
return runBlocking {
|
||||
val app = (application as LGApp)
|
||||
|
||||
try {
|
||||
val token = app.credentialStore.spotifyToken
|
||||
?: throw SpotifyException.ReAuthenticationNeededException()
|
||||
val usesPkceAuth = token.refreshToken != null
|
||||
val api = (if (usesPkceAuth) app.credentialStore.getSpotifyClientPkceApi()
|
||||
else app.credentialStore.getSpotifyImplicitGrantApi())
|
||||
?: throw SpotifyException.ReAuthenticationNeededException()
|
||||
|
||||
block(api)
|
||||
} catch (e: SpotifyException) {
|
||||
e.printStackTrace()
|
||||
val api = app.credentialStore.getSpotifyClientPkceApi()!!
|
||||
if (!alreadyTriedToReauthenticate) {
|
||||
try {
|
||||
api.refreshToken()
|
||||
app.credentialStore.spotifyToken = api.token
|
||||
block(api)
|
||||
} catch (e: SpotifyException.ReAuthenticationNeededException) {
|
||||
e.printStackTrace()
|
||||
return@runBlocking guardValidSpotifyApi(
|
||||
classBackTo = classBackTo,
|
||||
alreadyTriedToReauthenticate = true,
|
||||
block = block
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTrace()
|
||||
return@runBlocking guardValidSpotifyApi(
|
||||
classBackTo = classBackTo,
|
||||
alreadyTriedToReauthenticate = true,
|
||||
block = block
|
||||
)
|
||||
}
|
||||
} else {
|
||||
//pkceClassBackTo = classBackTo
|
||||
startSpotifyClientPkceLoginActivity(SpotifyPkceLoginActivityImpl::class.java)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsPage(
|
||||
goBack: () -> Unit,
|
||||
viewModel: SettingsViewModel,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
// TODO: on RTL languages all newer(only?) android systems change the direction of the status bar
|
||||
// including all its children, so we need to figure out how to position stuff to the right
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
) {
|
||||
LGIconButton(tooltip = stringResource(id = R.string.go_back), onClick = goBack) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(
|
||||
id = R.string.go_back
|
||||
) )
|
||||
}
|
||||
Text(text = stringResource(id = R.string.settings), style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Appearance(viewModel, uiState)
|
||||
Lyrics(viewModel, uiState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Appearance(viewModel: SettingsViewModel, uiState: SettingsState) {
|
||||
var themeOpenDialog by remember { mutableStateOf(false) }
|
||||
|
||||
SettingGroup(title = stringResource(id = R.string.appearance)) {
|
||||
DialogSetting(
|
||||
title = stringResource(id = R.string.theme),
|
||||
value = Themes[uiState.appTheme]?.let { stringResource(it) } ?: "None",
|
||||
icon = Icons.Outlined.Palette,
|
||||
onClick = { themeOpenDialog = true }
|
||||
)
|
||||
}
|
||||
if (themeOpenDialog) {
|
||||
ThemeDialog(
|
||||
uiState = uiState,
|
||||
onDismiss = { themeOpenDialog = false },
|
||||
onThemeChange = {
|
||||
viewModel.setTheme(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemeDialog(
|
||||
uiState: SettingsState,
|
||||
onDismiss: () -> Unit,
|
||||
onThemeChange: (v: Theme) -> Unit
|
||||
) {
|
||||
LGAlertDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = stringResource(id = R.string.theme),
|
||||
buttons = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(id = R.string.cancel))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column {
|
||||
LGRadioButton(
|
||||
text = stringResource(id = R.string.theme_light),
|
||||
onClick = {
|
||||
onThemeChange(Theme.LIGHT)
|
||||
},
|
||||
selected = uiState.appTheme == Theme.LIGHT.ordinal,
|
||||
)
|
||||
LGRadioButton(
|
||||
text = stringResource(id = R.string.theme_dark),
|
||||
onClick = {
|
||||
onThemeChange(Theme.DARK)
|
||||
},
|
||||
selected = uiState.appTheme == Theme.DARK.ordinal,
|
||||
)
|
||||
LGRadioButton(
|
||||
text = stringResource(id = R.string.theme_system),
|
||||
onClick = {
|
||||
onThemeChange(Theme.SYSTEM)
|
||||
},
|
||||
selected = uiState.appTheme == Theme.SYSTEM.ordinal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Lyrics(viewModel: SettingsViewModel, uiState: SettingsState) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as Activity
|
||||
val application = activity.application as LGApp
|
||||
|
||||
var isDialogOpen by remember { mutableStateOf(false) }
|
||||
var spotifyIdValue by remember { mutableStateOf("") }
|
||||
|
||||
SettingGroup(stringResource(id = R.string.lyrics)) {
|
||||
ToggleSetting(
|
||||
title = stringResource(id = R.string.use_spotify_api),
|
||||
checked = uiState.isSpotifyAuthed,
|
||||
icon = painterResource(id = R.drawable.spotify),
|
||||
onClick = {
|
||||
if (uiState.isSpotifyAuthed) {
|
||||
application.credentialStore.clear()
|
||||
viewModel.updateSpotifyAuthStatus(false)
|
||||
} else {
|
||||
isDialogOpen = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isDialogOpen) {
|
||||
LGAlertDialog(
|
||||
onDismiss = { isDialogOpen = false },
|
||||
title = stringResource(id = R.string.spotify_client_id),
|
||||
buttons = {
|
||||
TextButton(onClick = { isDialogOpen = false }) {
|
||||
Text(stringResource(id = R.string.cancel))
|
||||
}
|
||||
TextButton(onClick = {
|
||||
SpotifyPkceLoginActivityImpl.CLIENT_ID = spotifyIdValue
|
||||
SpotifyPkceLoginActivityImpl.settingsViewModel = viewModel
|
||||
activity.startSpotifyClientPkceLoginActivity(SpotifyPkceLoginActivityImpl::class.java)
|
||||
isDialogOpen = false
|
||||
}) {
|
||||
Text(stringResource(id = R.string.confirm))
|
||||
}
|
||||
}
|
||||
) {
|
||||
val linkColor = MaterialTheme.colorScheme.surfaceTint
|
||||
val spotifyDesc = annotatedStringResource(R.string.spotify_client_id_desc) {
|
||||
when (it.key) {
|
||||
"href" -> SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val textColor = LocalContentColor.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
ClickableText(
|
||||
spotifyDesc,
|
||||
onClick = { offset ->
|
||||
spotifyDesc.getStringAnnotations(tag = "href", start = offset, end = offset)
|
||||
.firstOrNull()
|
||||
?.let {
|
||||
uriHandler.openUri(it.item)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = spotifyIdValue,
|
||||
onValueChange = { spotifyIdValue = it },
|
||||
placeholder = {
|
||||
Text(stringResource(id = R.string.not_set))
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingGroup(title: String, content: @Composable () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Header(title)
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Header(title: String) {
|
||||
Row(Modifier.padding(start = 56.dp)) {
|
||||
Text(text = title, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogSetting(
|
||||
title: String,
|
||||
value: String,
|
||||
icon: ImageVector?,
|
||||
onClick: () -> Unit,
|
||||
singleLine : Boolean = false,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(if (enabled) 1f else 0.5f)
|
||||
.then(if (enabled) Modifier.clickable { onClick() } else Modifier)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.width(56.dp))
|
||||
} else {
|
||||
Box(modifier = Modifier.width(56.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = title, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(
|
||||
text = value,
|
||||
color = ColorSecondaryLight,
|
||||
fontSize = 14.sp,
|
||||
maxLines = if (singleLine) 1 else Int.MAX_VALUE,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(end = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToggleSetting(
|
||||
title: String,
|
||||
icon: Painter? = null,
|
||||
checked: Boolean,
|
||||
onClick: (Boolean) -> Unit)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier.clickable onClick@{
|
||||
onClick(!checked)
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.padding(top = 8.dp, end = 24.dp, bottom = 8.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(painter = icon, contentDescription = null, Modifier.width(56.dp))
|
||||
} else {
|
||||
Box(modifier = Modifier.width(56.dp))
|
||||
}
|
||||
Text(text = title, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1.0f))
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
thumbContent = if (checked) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
} else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
//val Purple80 = Color(0xFFD0BCFF)
|
||||
//val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
//val Pink80 = Color(0xFFEFB8C8)
|
||||
//
|
||||
//val Purple40 = Color(0xFF6650a4)
|
||||
//val PurpleGrey40 = Color(0xFF625b71)
|
||||
//val Pink40 = Color(0xFF7D5260)
|
||||
//
|
||||
//val ColorSecondaryLight = Color(0xFF999999)
|
||||
|
||||
val ColorPrimaryLight = Color(0xFFFFFFFF)
|
||||
val ColorPrimaryVariantLight = Color(0xFFEFEFEF)
|
||||
val ColorOnPrimaryLight = Color(0xFF000000)
|
||||
val ColorSecondaryLight = Color(0xFF999999)
|
||||
val ColorSecondaryVariantLight = Color(0xFF888888)
|
||||
val ColorOnSecondaryLight = Color(0xFF111111)
|
||||
val ColorAmoledLight = Color(0xFFFFFFFF)
|
||||
|
||||
val ColorPrimaryDark = Color(0xFF222222)
|
||||
val ColorPrimaryVariantDark = Color(0xFF202020)
|
||||
val ColorOnPrimaryDark = Color(0xFFFFFFFF)
|
||||
val ColorSecondaryDark = Color(0xFF696969)
|
||||
val ColorSecondaryVariantDark = Color(0xFF515151)
|
||||
val ColorOnSecondaryDark = Color(0xDFFFFFFF)
|
||||
val ColorAmoledDark = Color(0xFF000000)
|
||||
|
||||
val md_theme_light_primary = Color(0xFF7C44A8)
|
||||
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_primaryContainer = Color(0xFFF2DAFF)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF2E004D)
|
||||
val md_theme_light_secondary = Color(0xFF675A6E)
|
||||
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_secondaryContainer = Color(0xFFEEDDF5)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF221829)
|
||||
val md_theme_light_tertiary = Color(0xFF99405F)
|
||||
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_tertiaryContainer = Color(0xFFFFD9E1)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF3F001C)
|
||||
val md_theme_light_error = Color(0xFFBA1A1A)
|
||||
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410002)
|
||||
val md_theme_light_background = Color(0xFFFFFBFF)
|
||||
val md_theme_light_onBackground = Color(0xFF1D1B1E)
|
||||
val md_theme_light_surface = Color(0xFFFFFBFF)
|
||||
val md_theme_light_onSurface = Color(0xFF1D1B1E)
|
||||
val md_theme_light_surfaceVariant = Color(0xFFEADFEA)
|
||||
val md_theme_light_onSurfaceVariant = Color(0xFF4B454D)
|
||||
val md_theme_light_outline = Color(0xFF7C757E)
|
||||
val md_theme_light_inverseOnSurface = Color(0xFFF6EFF3)
|
||||
val md_theme_light_inverseSurface = Color(0xFF322F33)
|
||||
val md_theme_light_inversePrimary = Color(0xFFE1B6FF)
|
||||
val md_theme_light_shadow = Color(0xFF000000)
|
||||
val md_theme_light_surfaceTint = Color(0xFF7C44A8)
|
||||
val md_theme_light_outlineVariant = Color(0xFFCDC3CE)
|
||||
val md_theme_light_scrim = Color(0xFF000000)
|
||||
|
||||
val md_theme_dark_primary = Color(0xFFE1B6FF)
|
||||
val md_theme_dark_onPrimary = Color(0xFF4B0A76)
|
||||
val md_theme_dark_primaryContainer = Color(0xFF632A8E)
|
||||
val md_theme_dark_onPrimaryContainer = Color(0xFFF2DAFF)
|
||||
val md_theme_dark_secondary = Color(0xFFD2C1D9)
|
||||
val md_theme_dark_onSecondary = Color(0xFF372C3F)
|
||||
val md_theme_dark_secondaryContainer = Color(0xFF4E4256)
|
||||
val md_theme_dark_onSecondaryContainer = Color(0xFFEEDDF5)
|
||||
val md_theme_dark_tertiary = Color(0xFFFFB1C6)
|
||||
val md_theme_dark_onTertiary = Color(0xFF5E1131)
|
||||
val md_theme_dark_tertiaryContainer = Color(0xFF7B2947)
|
||||
val md_theme_dark_onTertiaryContainer = Color(0xFFFFD9E1)
|
||||
val md_theme_dark_error = Color(0xFFFFB4AB)
|
||||
val md_theme_dark_errorContainer = Color(0xFF93000A)
|
||||
val md_theme_dark_onError = Color(0xFF690005)
|
||||
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_background = Color(0xFF1D1B1E)
|
||||
val md_theme_dark_onBackground = Color(0xFFE7E1E5)
|
||||
val md_theme_dark_surface = Color(0xFF1D1B1E)
|
||||
val md_theme_dark_onSurface = Color(0xFFE7E1E5)
|
||||
val md_theme_dark_surfaceVariant = Color(0xFF4B454D)
|
||||
val md_theme_dark_onSurfaceVariant = Color(0xFFCDC3CE)
|
||||
val md_theme_dark_outline = Color(0xFF968E98)
|
||||
val md_theme_dark_inverseOnSurface = Color(0xFF1D1B1E)
|
||||
val md_theme_dark_inverseSurface = Color(0xFFE7E1E5)
|
||||
val md_theme_dark_inversePrimary = Color(0xFF7C44A8)
|
||||
val md_theme_dark_shadow = Color(0xFF000000)
|
||||
val md_theme_dark_surfaceTint = Color(0xFFE1B6FF)
|
||||
val md_theme_dark_outlineVariant = Color(0xFF4B454D)
|
||||
val md_theme_dark_scrim = Color(0xFF000000)
|
||||
|
||||
val seed = Color(0xFF662D91)
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onError = md_theme_light_onError,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||
inverseSurface = md_theme_light_inverseSurface,
|
||||
inversePrimary = md_theme_light_inversePrimary,
|
||||
surfaceTint = md_theme_light_surfaceTint,
|
||||
outlineVariant = md_theme_light_outlineVariant,
|
||||
scrim = md_theme_light_scrim,
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
primaryContainer = md_theme_dark_primaryContainer,
|
||||
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||
secondary = md_theme_dark_secondary,
|
||||
onSecondary = md_theme_dark_onSecondary,
|
||||
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||
tertiary = md_theme_dark_tertiary,
|
||||
onTertiary = md_theme_dark_onTertiary,
|
||||
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||
error = md_theme_dark_error,
|
||||
errorContainer = md_theme_dark_errorContainer,
|
||||
onError = md_theme_dark_onError,
|
||||
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
surface = md_theme_dark_surface,
|
||||
onSurface = md_theme_dark_onSurface,
|
||||
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||
outline = md_theme_dark_outline,
|
||||
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||
inverseSurface = md_theme_dark_inverseSurface,
|
||||
inversePrimary = md_theme_dark_inversePrimary,
|
||||
surfaceTint = md_theme_dark_surfaceTint,
|
||||
outlineVariant = md_theme_dark_outlineVariant,
|
||||
scrim = md_theme_dark_scrim,
|
||||
)
|
||||
|
||||
|
||||
@Composable
|
||||
fun LyricsGrabbrTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = Color.Transparent.toArgb()
|
||||
window.navigationBarColor = Color.Transparent.toArgb()
|
||||
// allows pages to extend to the status bar
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(darkTheme) {
|
||||
(context as ComponentActivity).enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.TRANSPARENT
|
||||
),
|
||||
navigationBarStyle = SystemBarStyle.auto(
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.TRANSPARENT
|
||||
)
|
||||
)
|
||||
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.illusionman1212.lyricsgrabbr.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.illusionman1212.lyricsgrabbr.utils
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.Call
|
||||
import okhttp3.Response
|
||||
import okhttp3.Callback
|
||||
import okio.IOException
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val callback =
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
continuation.resume(response) {
|
||||
response.body?.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
// Don't bother with resuming the continuation if it is already cancelled.
|
||||
if (continuation.isCancelled) return
|
||||
val exception = IOException(e.message, e).apply { stackTrace = callStack }
|
||||
continuation.resumeWithException(exception)
|
||||
}
|
||||
}
|
||||
|
||||
enqueue(callback)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
try {
|
||||
cancel()
|
||||
} catch (ex: Throwable) {
|
||||
// Ignore cancel exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Call.await(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
return await(callStack)
|
||||
}
|
||||
|
||||
suspend fun Call.awaitSuccess(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
val response = await(callStack)
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw HttpException(response.code).apply { stackTrace = callStack }
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
class HttpException(code: Int) : IllegalStateException("HTTP error $code")
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.illusionman1212.lyricsgrabbr.utils
|
||||
|
||||
import android.text.Annotation
|
||||
import android.text.SpannedString
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
|
||||
// Native stylization is coming soon
|
||||
// https://issuetracker.google.com/issues/139320238
|
||||
@Composable
|
||||
fun annotatedStringResource(
|
||||
@StringRes id: Int,
|
||||
spanStyles: (Annotation) -> SpanStyle? = { null }
|
||||
): AnnotatedString {
|
||||
val resources = LocalContext.current.resources
|
||||
val spannedString = SpannedString(resources.getText(id))
|
||||
val resultBuilder = AnnotatedString.Builder()
|
||||
resultBuilder.append(spannedString.toString())
|
||||
spannedString.getSpans<Annotation>(0, spannedString.length, Annotation::class.java).forEach { annotation ->
|
||||
val spanStart = spannedString.getSpanStart(annotation)
|
||||
val spanEnd = spannedString.getSpanEnd(annotation)
|
||||
resultBuilder.addStringAnnotation(
|
||||
tag = annotation.key,
|
||||
annotation = annotation.value,
|
||||
start = spanStart,
|
||||
end = spanEnd
|
||||
)
|
||||
spanStyles(annotation)?.let { resultBuilder.addStyle(it, spanStart, spanEnd) }
|
||||
}
|
||||
return resultBuilder.toAnnotatedString()
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.illusionman1212.lyricsgrabbr.viewmodels
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.adamratzman.spotify.SpotifyClientApi
|
||||
import com.illusionman1212.lyricsgrabbr.LGApp
|
||||
import com.illusionman1212.lyricsgrabbr.NotificationEvent
|
||||
import com.illusionman1212.lyricsgrabbr.R
|
||||
import com.illusionman1212.lyricsgrabbr.data.HomeRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object UriSerializer : KSerializer<Uri> {
|
||||
override val descriptor: SerialDescriptor = String.serializer().descriptor
|
||||
|
||||
override fun deserialize(decoder: Decoder): Uri {
|
||||
return Uri.parse(decoder.decodeString())
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Uri) {
|
||||
encoder.encodeString(value.toString())
|
||||
}
|
||||
}
|
||||
|
||||
data class SearchResult(
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val url: Uri,
|
||||
val artistUrl: Uri,
|
||||
val thumbnail: Uri,
|
||||
val id: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Resources(
|
||||
@Serializable(with = UriSerializer::class)
|
||||
val thumbnail: Uri,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Artist(
|
||||
val name: String,
|
||||
@Serializable(with = UriSerializer::class)
|
||||
val url: Uri,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Meta(
|
||||
val title: String,
|
||||
val primaryArtist: Artist,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class IntermediateResult(
|
||||
@Serializable(with = UriSerializer::class)
|
||||
val url: Uri,
|
||||
val meta: Meta,
|
||||
val resources: Resources,
|
||||
val id: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchResultsResponse(
|
||||
val all: List<IntermediateResult>
|
||||
)
|
||||
|
||||
data class HomeState(
|
||||
val isLoading: Boolean = false,
|
||||
val results: List<SearchResult> = emptyList(),
|
||||
val notification: NotificationEvent? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class HomeViewModel(private val context: Context, private val homeRepository: HomeRepository): ViewModel() {
|
||||
private val _uiState = MutableStateFlow(HomeState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun getListeningPermission(): Boolean {
|
||||
return homeRepository.isPermissionGranted()
|
||||
}
|
||||
|
||||
fun setSong(song: NotificationEvent) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, notification = song, error = null)
|
||||
}
|
||||
|
||||
fun makeRequestToSpotify(spotifyApi: SpotifyClientApi) {
|
||||
viewModelScope.launch {
|
||||
val track = spotifyApi.player.getCurrentlyPlaying()?.item
|
||||
|
||||
if (track?.id == null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = context.resources.getString(R.string.no_spotify_song_playing)
|
||||
)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = null,
|
||||
results = listOf(SearchResult(
|
||||
title = _uiState.value.notification!!.title,
|
||||
artist = _uiState.value.notification!!.artist,
|
||||
url = track.asTrack?.externalUrls?.spotify?.toUri() ?: Uri.EMPTY,
|
||||
artistUrl = track.asTrack?.artists?.get(0)?.externalUrls?.spotify?.toUri() ?: Uri.EMPTY,
|
||||
thumbnail = track.asTrack?.album?.images?.get(0)?.url?.toUri() ?: Uri.EMPTY,
|
||||
id = track.id!!,
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun makeRequestToGenius(song: String, artist: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { HomeState(true, uiState.value.results, uiState.value.notification, null) }
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val (res, isSuccess) = homeRepository.makeSearchRequest(song, artist)
|
||||
if (isSuccess) {
|
||||
val results = json.decodeFromString(SearchResultsResponse.serializer(), res!!).all.map {
|
||||
SearchResult(
|
||||
title = it.meta.title,
|
||||
artist = it.meta.primaryArtist.name,
|
||||
url = it.url,
|
||||
artistUrl = it.meta.primaryArtist.url,
|
||||
thumbnail = it.resources.thumbnail,
|
||||
id = it.id.toString()
|
||||
)
|
||||
}
|
||||
_uiState.update { HomeState(false, results, uiState.value.notification, null) }
|
||||
} else {
|
||||
_uiState.update { HomeState(
|
||||
false,
|
||||
emptyList(),
|
||||
uiState.value.notification,
|
||||
res
|
||||
) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val application = (this[APPLICATION_KEY] as LGApp)
|
||||
HomeViewModel(application.applicationContext, application.homeRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.illusionman1212.lyricsgrabbr.viewmodels
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.illusionman1212.lyricsgrabbr.LGApp
|
||||
import com.illusionman1212.lyricsgrabbr.data.LyricsRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class LyricsState(
|
||||
// set by Home screen
|
||||
val song: String = "",
|
||||
val artist: String = "",
|
||||
val url: Uri = Uri.EMPTY,
|
||||
val trackId: String = "",
|
||||
val useSpotifyApi: Boolean = false,
|
||||
|
||||
// set by Lyrics screen
|
||||
val lyrics: String = "",
|
||||
val isLoading: Boolean = true,
|
||||
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class LyricsViewModel(private val lyricsRepository: LyricsRepository): ViewModel() {
|
||||
private val _uiState = MutableStateFlow(LyricsState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
fun onSongChange(trackId: String, title: String, subtitle: String, url: Uri, useSpotifyApi: Boolean) {
|
||||
_uiState.update {
|
||||
it.copy(song = title, artist = subtitle, url = url, useSpotifyApi = useSpotifyApi, trackId = trackId)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchLyricsFromSpotify() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, lyrics = "")
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val (lyrics, isSuccess) = lyricsRepository.fetchLyricsFromSpotify(uiState.value.trackId)
|
||||
if (isSuccess) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, lyrics = lyrics)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, error = lyrics, lyrics = "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchLyricsFromGenius() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, lyrics = "")
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val lyrics = lyricsRepository.fetchLyricsFromGenius(uiState.value.url)
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, lyrics = lyrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val application = (this[APPLICATION_KEY] as LGApp)
|
||||
LyricsViewModel(application.lyricsRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.illusionman1212.lyricsgrabbr.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.illusionman1212.lyricsgrabbr.LGApp
|
||||
import com.illusionman1212.lyricsgrabbr.data.SettingsPreferencesRepository
|
||||
import com.illusionman1212.lyricsgrabbr.data.Theme
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SettingsState(
|
||||
val appTheme: Int = Theme.SYSTEM.ordinal,
|
||||
val isSpotifyAuthed: Boolean = false,
|
||||
)
|
||||
|
||||
class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesRepository): ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SettingsState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
suspend fun getAppTheme(): Int {
|
||||
return settingsPrefsRepo.preferences.first().appTheme
|
||||
}
|
||||
|
||||
fun setTheme(theme: Theme) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
settingsPrefsRepo.setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSpotifyAuthStatus(isAuthed: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(isSpotifyAuthed = isAuthed)
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsPrefsRepo.preferences.collectLatest {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
appTheme = it.appTheme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val application = (this[APPLICATION_KEY] as LGApp)
|
||||
SettingsViewModel(application.settingsPreferencesRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="0.15333334"
|
||||
android:scaleY="0.15333334"
|
||||
android:translateX="0.96"
|
||||
android:translateY="0.96">
|
||||
<path
|
||||
android:pathData="M0,144L144,144L144,0L0,0Z"
|
||||
android:strokeAlpha="0"
|
||||
android:fillColor="#662d91"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"
|
||||
android:fillAlpha="0"/>
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M-54.542,-54.542L198.542,-54.542L198.542,198.542L-54.542,198.542Z"/>
|
||||
<path
|
||||
android:pathData="m73.388,111.487c1.275,12.078 -11.656,23.347 -28.884,25.166 -17.229,1.821 -32.231,-6.498 -33.506,-18.576 -1.275,-12.08 11.658,-23.347 28.887,-25.168C57.113,91.091 72.113,99.41 73.388,111.487"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="M73.497,111.476 L65.803,113.156C64.473,113.447 63.279,111.45 63.139,108.697l-4.431,-86.775c-0.168,-3.276 1.214,-5.99 2.787,-5.479L66.926,18.209c1.085,0.354 1.919,2.168 2.034,4.424z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="m117.074,40.043 l-51.894,-7.185c-4.11,-0.569 -6.983,-4.366 -6.414,-8.478l0.033,-2.96c0.972,-2.26 0.158,-6.942 3.095,-6.535l57.616,7.561c2.936,0.408 4.987,3.117 4.579,6.053l-0.963,6.962c-0.405,2.936 -3.114,4.987 -6.051,4.581"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="m127.042,56.426 l-31.802,10.135c-0.48,0.155 -1.005,-0.251 -1.066,-0.82l-0.021,-0.199c-0.192,-1.807 -0.89,-3.553 -1.994,-4.991 -0.302,-0.396 -0.209,-0.968 0.19,-1.155L123.028,45.063C123.421,44.88 125.704,47.334 125.704,47.334 126.929,48.908 127.677,50.849 127.825,52.838 127.825,52.838 127.649,56.231 127.042,56.426"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="m127.3,91.063 l-31.697,-2.519c-0.314,-0.026 -0.523,-0.403 -0.424,-0.769l0.173,-0.651c0.455,-1.687 0.462,-3.503 0.028,-5.191l-0.122,-0.464c-0.075,-0.293 0.098,-0.595 0.349,-0.609L127.816,79.002C128.114,78.983 129.182,82.214 129.182,82.214 129.677,84.068 129.644,86.076 129.091,87.916 129.091,87.916 127.788,91.103 127.3,91.063"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="m121.449,122.465 l-28.636,-12.04c-0.284,-0.117 -0.359,-0.541 -0.152,-0.858l0.363,-0.565c0.947,-1.465 1.526,-3.187 1.645,-4.923l0.035,-0.478c0.021,-0.3 0.274,-0.534 0.513,-0.473L125.693,111.163C125.976,111.236 125.96,114.631 125.96,114.631 125.838,116.543 125.18,118.442 124.092,120.021 124.092,120.021 121.887,122.65 121.449,122.465"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 499 B |
|
After Width: | Height: | Size: 343 B |
@@ -0,0 +1,50 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:scaleX="0.75"
|
||||
android:scaleY="0.75">
|
||||
<path
|
||||
android:pathData="M0,144L144,144L144,0L0,0Z"
|
||||
android:strokeAlpha="0"
|
||||
android:fillColor="#662d91"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"
|
||||
android:fillAlpha="0"/>
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M17.809,17.809L126.191,17.809L126.191,126.191L17.809,126.191Z"/>
|
||||
<path
|
||||
android:pathData="m72.594,88.91c0.546,5.172 -4.992,9.998 -12.37,10.777 -7.378,0.78 -13.803,-2.783 -14.349,-7.955 -0.546,-5.173 4.993,-9.998 12.371,-10.778C65.625,80.175 72.048,83.738 72.594,88.91"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="M72.641,88.905 L69.346,89.625C68.776,89.749 68.265,88.894 68.205,87.715l-1.898,-37.161c-0.072,-1.403 0.52,-2.565 1.193,-2.346L69.827,48.964c0.464,0.152 0.822,0.928 0.871,1.895z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="m91.303,58.314 l-22.224,-3.077c-1.76,-0.244 -2.991,-1.87 -2.747,-3.631l0.014,-1.268c0.416,-0.968 0.068,-2.973 1.325,-2.798l24.674,3.238c1.257,0.175 2.136,1.335 1.961,2.592l-0.412,2.982c-0.174,1.257 -1.334,2.136 -2.591,1.962"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="m95.572,65.33 l-13.619,4.34c-0.206,0.066 -0.431,-0.107 -0.457,-0.351l-0.009,-0.085c-0.082,-0.774 -0.381,-1.521 -0.854,-2.138 -0.129,-0.17 -0.089,-0.414 0.081,-0.495L93.852,60.464C94.021,60.386 94.999,61.437 94.999,61.437 95.523,62.111 95.844,62.942 95.907,63.794 95.907,63.794 95.831,65.247 95.572,65.33"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="m95.682,80.164 l-13.574,-1.079c-0.134,-0.011 -0.224,-0.173 -0.182,-0.329l0.074,-0.279c0.195,-0.723 0.198,-1.5 0.012,-2.223l-0.052,-0.199c-0.032,-0.125 0.042,-0.255 0.15,-0.261L95.903,74.998C96.03,74.99 96.488,76.374 96.488,76.374 96.7,77.168 96.686,78.028 96.449,78.816 96.449,78.816 95.891,80.181 95.682,80.164"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="m93.176,93.612 l-12.263,-5.156c-0.121,-0.05 -0.154,-0.232 -0.065,-0.367l0.156,-0.242c0.405,-0.627 0.653,-1.365 0.704,-2.108l0.015,-0.205c0.009,-0.128 0.117,-0.229 0.22,-0.203L94.994,88.771C95.115,88.803 95.108,90.257 95.108,90.257 95.056,91.076 94.774,91.888 94.308,92.565 94.308,92.565 93.364,93.691 93.176,93.612"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 656 B |
|
After Width: | Height: | Size: 941 B |
@@ -0,0 +1,116 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:scaleX="0.7575"
|
||||
android:scaleY="0.7575"
|
||||
android:translateX="-0.54"
|
||||
android:translateY="-0.54">
|
||||
<path
|
||||
android:pathData="M0,144L144,144L144,0L0,0Z"
|
||||
android:fillColor="#662d91"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M17.809,17.809L126.191,17.809L126.191,126.191L17.809,126.191Z"/>
|
||||
<path
|
||||
android:pathData="M126.191,126.191L17.809,126.191l0,-108.383l108.383,-0z"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeLineJoin="miter"
|
||||
android:strokeWidth="1.0035449"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#662d91"
|
||||
android:fillAlpha="0"
|
||||
android:strokeLineCap="butt"/>
|
||||
<path
|
||||
android:pathData="m72.594,88.91c0.546,5.172 -4.992,9.998 -12.37,10.777 -7.378,0.78 -13.803,-2.783 -14.349,-7.955 -0.546,-5.173 4.993,-9.998 12.371,-10.778C65.625,80.175 72.048,83.738 72.594,88.91"
|
||||
android:strokeAlpha="0"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="m72.594,88.91c0.546,5.172 -4.992,9.998 -12.37,10.777 -7.378,0.78 -13.803,-2.783 -14.349,-7.955 -0.546,-5.173 4.993,-9.998 12.371,-10.778C65.625,80.175 72.048,83.738 72.594,88.91Z"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeLineJoin="miter"
|
||||
android:strokeWidth="1.0035449"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#ffffff"
|
||||
android:fillAlpha="0"
|
||||
android:strokeLineCap="butt"/>
|
||||
<path
|
||||
android:pathData="M72.641,88.905 L69.346,89.625C68.776,89.749 68.265,88.894 68.205,87.715l-1.898,-37.161c-0.072,-1.403 0.52,-2.565 1.193,-2.346L69.827,48.964c0.464,0.152 0.822,0.928 0.871,1.895z"
|
||||
android:strokeAlpha="0"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="M72.762,89.054 L69.347,89.779c-0.59,0.125 -1.12,-0.736 -1.182,-1.923L66.198,50.427c-0.074,-1.413 0.539,-2.583 1.237,-2.363l2.411,0.762c0.481,0.153 0.851,0.935 0.903,1.908z"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeLineJoin="miter"
|
||||
android:strokeWidth="0.7129946"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#ffffff"
|
||||
android:fillAlpha="0"
|
||||
android:strokeLineCap="butt"/>
|
||||
<path
|
||||
android:pathData="m91.303,58.314 l-22.224,-3.077c-1.76,-0.244 -2.991,-1.87 -2.747,-3.631l0.014,-1.268c0.416,-0.968 0.068,-2.973 1.325,-2.798l24.674,3.238c1.257,0.175 2.136,1.335 1.961,2.592l-0.412,2.982c-0.174,1.257 -1.334,2.136 -2.591,1.962"
|
||||
android:strokeAlpha="0"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="m95.572,65.33 l-13.619,4.34c-0.206,0.066 -0.431,-0.107 -0.457,-0.351l-0.009,-0.085c-0.082,-0.774 -0.381,-1.521 -0.854,-2.138 -0.129,-0.17 -0.089,-0.414 0.081,-0.495L93.852,60.464C94.021,60.386 94.999,61.437 94.999,61.437 95.523,62.111 95.844,62.942 95.907,63.794 95.907,63.794 95.831,65.247 95.572,65.33"
|
||||
android:strokeAlpha="0"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="m95.572,65.33 l-13.619,4.34c-0.206,0.066 -0.431,-0.107 -0.457,-0.351l-0.009,-0.085c-0.082,-0.774 -0.381,-1.521 -0.854,-2.138 -0.129,-0.17 -0.089,-0.414 0.081,-0.495L93.852,60.464C94.021,60.386 94.999,61.437 94.999,61.437 95.523,62.111 95.844,62.942 95.907,63.794 95.907,63.794 95.831,65.247 95.572,65.33Z"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeLineJoin="miter"
|
||||
android:strokeWidth="1.0035449"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#ffffff"
|
||||
android:fillAlpha="0"
|
||||
android:strokeLineCap="butt"/>
|
||||
<path
|
||||
android:pathData="m95.682,80.164 l-13.574,-1.079c-0.134,-0.011 -0.224,-0.173 -0.182,-0.329l0.074,-0.279c0.195,-0.723 0.198,-1.5 0.012,-2.223l-0.052,-0.199c-0.032,-0.125 0.042,-0.255 0.15,-0.261L95.903,74.998C96.03,74.99 96.488,76.374 96.488,76.374 96.7,77.168 96.686,78.028 96.449,78.816 96.449,78.816 95.891,80.181 95.682,80.164"
|
||||
android:strokeAlpha="0"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="m95.682,80.164 l-13.574,-1.079c-0.134,-0.011 -0.224,-0.173 -0.182,-0.329l0.074,-0.279c0.195,-0.723 0.198,-1.5 0.012,-2.223l-0.052,-0.199c-0.032,-0.125 0.042,-0.255 0.15,-0.261L95.903,74.998C96.03,74.99 96.488,76.374 96.488,76.374 96.7,77.168 96.686,78.028 96.449,78.816 96.449,78.816 95.891,80.181 95.682,80.164Z"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeLineJoin="miter"
|
||||
android:strokeWidth="1.0035449"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#ffffff"
|
||||
android:fillAlpha="0"
|
||||
android:strokeLineCap="butt"/>
|
||||
<path
|
||||
android:pathData="m93.176,93.612 l-12.263,-5.156c-0.121,-0.05 -0.154,-0.232 -0.065,-0.367l0.156,-0.242c0.405,-0.627 0.653,-1.365 0.704,-2.108l0.015,-0.205c0.009,-0.128 0.117,-0.229 0.22,-0.203L94.994,88.771C95.115,88.803 95.108,90.257 95.108,90.257 95.056,91.076 94.774,91.888 94.308,92.565 94.308,92.565 93.364,93.691 93.176,93.612"
|
||||
android:strokeAlpha="0"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"
|
||||
android:fillAlpha="0"/>
|
||||
<path
|
||||
android:pathData="m93.176,93.612 l-12.263,-5.156c-0.121,-0.05 -0.154,-0.232 -0.065,-0.367l0.156,-0.242c0.405,-0.627 0.653,-1.365 0.704,-2.108l0.015,-0.205c0.009,-0.128 0.117,-0.229 0.22,-0.203L94.994,88.771C95.115,88.803 95.108,90.257 95.108,90.257 95.056,91.076 94.774,91.888 94.308,92.565 94.308,92.565 93.364,93.691 93.176,93.612Z"
|
||||
android:strokeAlpha="0"
|
||||
android:strokeLineJoin="miter"
|
||||
android:strokeWidth="1.0035449"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#ffffff"
|
||||
android:fillAlpha="0"
|
||||
android:strokeLineCap="butt"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,50 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:scaleX="0.75"
|
||||
android:scaleY="0.75">
|
||||
<path
|
||||
android:pathData="M0,144L144,144L144,0L0,0Z"
|
||||
android:strokeAlpha="0"
|
||||
android:fillColor="#662d91"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"
|
||||
android:fillAlpha="0"/>
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M17.809,17.809L126.191,17.809L126.191,126.191L17.809,126.191Z"/>
|
||||
<path
|
||||
android:pathData="m72.594,88.91c0.546,5.172 -4.992,9.998 -12.37,10.777 -7.378,0.78 -13.803,-2.783 -14.349,-7.955 -0.546,-5.173 4.993,-9.998 12.371,-10.778C65.625,80.175 72.048,83.738 72.594,88.91"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="M72.641,88.905 L69.346,89.625C68.776,89.749 68.265,88.894 68.205,87.715l-1.898,-37.161c-0.072,-1.403 0.52,-2.565 1.193,-2.346L69.827,48.964c0.464,0.152 0.822,0.928 0.871,1.895z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="m91.303,58.314 l-22.224,-3.077c-1.76,-0.244 -2.991,-1.87 -2.747,-3.631l0.014,-1.268c0.416,-0.968 0.068,-2.973 1.325,-2.798l24.674,3.238c1.257,0.175 2.136,1.335 1.961,2.592l-0.412,2.982c-0.174,1.257 -1.334,2.136 -2.591,1.962"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="m95.572,65.33 l-13.619,4.34c-0.206,0.066 -0.431,-0.107 -0.457,-0.351l-0.009,-0.085c-0.082,-0.774 -0.381,-1.521 -0.854,-2.138 -0.129,-0.17 -0.089,-0.414 0.081,-0.495L93.852,60.464C94.021,60.386 94.999,61.437 94.999,61.437 95.523,62.111 95.844,62.942 95.907,63.794 95.907,63.794 95.831,65.247 95.572,65.33"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="m95.682,80.164 l-13.574,-1.079c-0.134,-0.011 -0.224,-0.173 -0.182,-0.329l0.074,-0.279c0.195,-0.723 0.198,-1.5 0.012,-2.223l-0.052,-0.199c-0.032,-0.125 0.042,-0.255 0.15,-0.261L95.903,74.998C96.03,74.99 96.488,76.374 96.488,76.374 96.7,77.168 96.686,78.028 96.449,78.816 96.449,78.816 95.891,80.181 95.682,80.164"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
<path
|
||||
android:pathData="m93.176,93.612 l-12.263,-5.156c-0.121,-0.05 -0.154,-0.232 -0.065,-0.367l0.156,-0.242c0.405,-0.627 0.653,-1.365 0.704,-2.108l0.015,-0.205c0.009,-0.128 0.117,-0.229 0.22,-0.203L94.994,88.771C95.115,88.803 95.108,90.257 95.108,90.257 95.056,91.076 94.774,91.888 94.308,92.565 94.308,92.565 93.364,93.691 93.176,93.612"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="nonZero"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Lyrics Grabbr</string>
|
||||
|
||||
<!-- General -->
|
||||
<string name="go_back">رجوع</string>
|
||||
<string name="cancel">إلغاء</string>
|
||||
<string name="confirm">تأكيد</string>
|
||||
<string name="timeout">لقد انتهت مهلة الطلب، حاول مرة أخرى لاحقًا</string>
|
||||
<string name="generic_error">لقد حدث خطأ، حاول مرة أخرى لاحقاً</string>
|
||||
<string name="retry">أعد المحاولة</string>
|
||||
<string name="spotify_no_lyrics_error">عذرًا، لا يحتوي Spotify على كلمات لهذه الأغنية</string>
|
||||
|
||||
<!-- Home -->
|
||||
<string name="no_supported_player">لا يوجد مشغل مدعوم قيد التشغيل</string>
|
||||
<string name="open_settings">فتح الإعدادات</string>
|
||||
<string name="notification_listen_permission">إذن قراءة الإشعارات</string>
|
||||
<string name="notification_listen_desc">هذا التطبيق يحتاج إذن قراءة الإشعارات للعمل بشكل صحيح. يرجى منح الإذن في شاشة الإعدادات التالية.\nإحذر أن الطريقة الوحيدة لإيقاف الخدمة هي إبطال إذن قراءة الإشعارات.</string>
|
||||
<string name="no_results">لم يتم العثور على نتائج</string>
|
||||
<string name="found_results">تم العثور على %1$d نتيجة</string>
|
||||
<string name="powered_by">مشغل بواسطة<annotation href="https://github.com/sv443/geniURL">geniURL</annotation></string>
|
||||
<string name="open_song_page">افتح صفحة الأغنية</string>
|
||||
<string name="open_artist_page">افتح صفحة الفنان</string>
|
||||
<string name="notification_post_permission">مطلوب إذن الإشعارات</string>
|
||||
<string name="notification_post_permission_desc">يتطلب هذا التطبيق أذونات الإشعارات لبدء خدمة مقدمة بغرض الاستماع إلى إشعارات الموسيقى، ولا يمكن أن يعمل بدونها.</string>
|
||||
<string name="notification_post_permission_not_granted">لم يتم منح إذن الإشعارات، لن يعمل التطبيق بشكل صحيح</string>
|
||||
<string name="grant_permission">منح الإذن</string>
|
||||
<string name="no_spotify_song_playing">No song is playing on Spotify</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings">الإعدادات</string>
|
||||
<string name="appearance">المظهر</string>
|
||||
<string name="theme">مظهر التطبيق</string>
|
||||
<string name="theme_light">نهار</string>
|
||||
<string name="theme_dark">ليلي</string>
|
||||
<string name="theme_system">استخدم مظهر النظام</string>
|
||||
<string name="use_spotify_api">استخدم Spotify للكلمات</string>
|
||||
<string name="spotify_client_id">Spotify Client ID</string>
|
||||
<string name="not_set">خالي</string>
|
||||
<string name="spotify_client_id_desc">يمكن لهذا التطبيق استخدام واجهة برمجة تطبيقات الويب الخاصة بـ Spotify للحصول على كلمات الأغاني. تحتاج إلى تقديم Spotify Client ID لاستخدام هذه الميزة. يمكنك الحصول على واحدة من <annotation href="https://developer.spotify.com/dashboard/applications">هنا</annotation>.\nلا يعمل هذا إلا إذا كنت تقوم بتشغيل الموسيقى من مشغل Spotify.</string>
|
||||
|
||||
<!-- Lyrics -->
|
||||
<string name="lyrics">كلمات الأغنية</string>
|
||||
<string name="instrumental">هذه الأغنية موسيقية</string>
|
||||
|
||||
<!-- Notification -->
|
||||
<string name="service_running">الخدمة قيد التشغيل</string>
|
||||
<string name="stop_service">وقف الخدمة</string>
|
||||
<string name="service_notif_desc_init">قم بتشغيل بعض الأغاني على مشغل مدعوم للحصول على كلمات الأغاني</string>
|
||||
<string name="service_notif_desc_playing">انقر للحصول على كلمات الأغاني</string>
|
||||
<string name="now_playing">يتم الآن تشغيل %1$s لـ %2$s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,51 @@
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Lyrics Grabbr</string>
|
||||
|
||||
<!-- General -->
|
||||
<string name="go_back">Go Back</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="confirm">Confirm</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="retry">Retry</string>
|
||||
<string name="spotify_no_lyrics_error">Sorry, Spotify doesn\'t have lyrics for this song</string>
|
||||
|
||||
<!-- Home -->
|
||||
<string name="no_supported_player">No supported player is running</string>
|
||||
<string name="open_settings">Open Settings</string>
|
||||
<string name="notification_listen_permission">Notification Listening Permission</string>
|
||||
<string name="notification_listen_desc">This app requires permission to listen to notifications to function properly. Please grant permission in the next screen.\nBEWARE that the only way to stop the service is to revoke the notification listening permission.</string>
|
||||
<string name="no_results">No results found</string>
|
||||
<string name="found_results">Found %1$d result(s)</string>
|
||||
<string name="powered_by">Powered By <annotation href="https://github.com/sv443/geniURL">geniURL</annotation></string>
|
||||
<string name="open_song_page">Open Song Page</string>
|
||||
<string name="open_artist_page">Open Artist Page</string>
|
||||
<string name="notification_post_permission">Notification Permission Required</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="grant_permission">Grant Permission</string>
|
||||
<string name="no_spotify_song_playing">No song is playing on Spotify</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings">Settings</string>
|
||||
<string name="appearance">Appearance</string>
|
||||
<string name="theme">Theme</string>
|
||||
<string name="theme_light">Light</string>
|
||||
<string name="theme_dark">Dark</string>
|
||||
<string name="theme_system">Use System Theme</string>
|
||||
<string name="use_spotify_api">Use Spotify for lyrics</string>
|
||||
<string name="spotify_client_id" translatable="false">Spotify Client ID</string>
|
||||
<string name="not_set">Not Set</string>
|
||||
<string name="spotify_client_id_desc">This app can use Spotify\'s Web API to get lyrics. You need to provide a Spotify Client ID to use this feature. You can get one from <annotation href="https://developer.spotify.com/dashboard/applications">here</annotation>.\nThis only works if you\'re playing music from the Spotify player.</string>
|
||||
|
||||
<!-- Lyrics -->
|
||||
<string name="lyrics">Lyrics</string>
|
||||
<string name="instrumental">This song is an instrumental</string>
|
||||
|
||||
<!-- Notification -->
|
||||
<string name="service_running">Service Running</string>
|
||||
<string name="stop_service">Stop Service</string>
|
||||
<string name="service_notif_desc_init">Play some songs on a supported player to get lyrics</string>
|
||||
<string name="service_notif_desc_playing">Tap to get lyrics</string>
|
||||
<string name="now_playing">Now Playing %1$s By %2$s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.LyricsGrabbr" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||
|
||||
<style name="Theme.LyricsGrabbr.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">#FF662D91</item>
|
||||
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||
<item name="windowSplashScreenAnimationDuration">800</item>
|
||||
|
||||
<item name="postSplashScreenTheme">@style/Theme.LyricsGrabbr</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older that API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.illusionman1212.lyricsgrabbr
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||