From 6f3555b1d39e7a7070c88eb4ac23630380f2e70d Mon Sep 17 00:00:00 2001 From: IllusionMan1212 Date: Thu, 18 Jan 2024 03:17:01 +0200 Subject: [PATCH] rewrite the app in native kotlin with compose --- .gitignore | 46 -- .gitmodules | 3 - .metadata | 10 - 3rdparty/flutter | 1 - analysis_options.yaml | 29 - android/.gitignore | 13 - android/app/build.gradle | 95 --- android/app/src/debug/AndroidManifest.xml | 7 - android/app/src/main/AndroidManifest.xml | 54 -- .../lyricsgrabbr/lyricsgrabbr/MainActivity.kt | 67 --- .../lyricsgrabbr/NotificationStreamHandler.kt | 51 -- .../res/drawable-v21/launch_background.xml | 15 - .../app/src/main/res/values-night/styles.xml | 18 - android/app/src/main/res/values/styles.xml | 18 - android/app/src/profile/AndroidManifest.xml | 7 - android/build.gradle | 31 - android/gradle.properties | 16 - android/settings.gradle | 11 - app/.gitignore | 1 + app/build.gradle.kts | 107 ++++ app/proguard-rules.pro | 21 + .../lyricsgrabbr/ExampleInstrumentedTest.kt | 24 + app/src/main/AndroidManifest.xml | 62 ++ .../src/main/ic_launcher-playstore.png | Bin .../com/illusionman1212/lyricsgrabbr/LGApp.kt | 38 ++ .../lyricsgrabbr/MainActivity.kt | 198 +++++++ .../lyricsgrabbr/NotificationListener.kt | 105 ++-- .../illusionman1212/lyricsgrabbr/Routes.kt | 8 + .../SpotifyPkceLoginActivityImpl.kt | 29 + .../lyricsgrabbr/data/HomeRepository.kt | 69 +++ .../lyricsgrabbr/data/LyricsRepository.kt | 133 +++++ .../data/SettingsPreferencesRepository.kt | 39 ++ .../data/network/NetworkHelper.kt | 24 + .../lyricsgrabbr/data/network/Requests.kt | 31 + .../ui/components/ForwardingPainter.kt | 77 +++ .../ui/components/LGAlertDialog.kt | 53 ++ .../ui/components/LGIconButton.kt | 74 +++ .../ui/components/LGRadioButton.kt | 28 + .../lyricsgrabbr/ui/screens/Home.kt | 552 ++++++++++++++++++ .../lyricsgrabbr/ui/screens/Lyrics.kt | 152 +++++ .../lyricsgrabbr/ui/screens/Settings.kt | 404 +++++++++++++ .../lyricsgrabbr/ui/theme/Color.kt | 93 +++ .../lyricsgrabbr/ui/theme/Theme.kt | 138 +++++ .../lyricsgrabbr/ui/theme/Type.kt | 34 ++ .../lyricsgrabbr/utils/OkHttpExtensions.kt | 58 ++ .../lyricsgrabbr/utils/Resources.kt | 34 ++ .../lyricsgrabbr/viewmodels/HomeViewModel.kt | 170 ++++++ .../viewmodels/LyricsViewModel.kt | 78 +++ .../viewmodels/SettingsViewModel.kt | 60 ++ .../drawable-anydpi-v24/ic_notification.xml | 0 .../res/drawable-hdpi/ic_notification.png | Bin .../res/drawable-mdpi/ic_notification.png | Bin .../drawable-v24}/ic_launcher_foreground.xml | 0 .../res/drawable-xhdpi/ic_notification.png | Bin .../res/drawable-xxhdpi/ic_notification.png | Bin .../res/drawable/ic_launcher_background.xml | 0 .../res/drawable/ic_launcher_foreground.xml | 50 ++ .../main/res/drawable/launch_background.xml | 0 app/src/main/res/drawable/spotify.xml | 9 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin .../res/mipmap-hdpi/ic_launcher_round.png | Bin .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin .../res/mipmap-mdpi/ic_launcher_round.png | Bin .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin .../res/mipmap-xhdpi/ic_launcher_round.png | Bin .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin app/src/main/res/values-ar/strings.xml | 52 ++ app/src/main/res/values/strings.xml | 51 ++ app/src/main/res/values/themes.xml | 14 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../lyricsgrabbr/ExampleUnitTest.kt | 17 + build.gradle.kts | 5 + gradle.properties | 23 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../wrapper/gradle-wrapper.properties | 4 +- gradlew | 185 ++++++ gradlew.bat | 89 +++ lib/lyrics.dart | 123 ---- lib/main.dart | 322 ---------- lib/notification_listener.dart | 85 --- lib/search_result.dart | 171 ------ lib/settings.dart | 188 ------ lib/theme_provider.dart | 43 -- pubspec.lock | 442 -------------- pubspec.yaml | 90 --- settings.gradle.kts | 18 + 92 files changed, 3391 insertions(+), 2008 deletions(-) delete mode 100644 .gitignore delete mode 100644 .gitmodules delete mode 100644 .metadata delete mode 160000 3rdparty/flutter delete mode 100644 analysis_options.yaml delete mode 100644 android/.gitignore delete mode 100644 android/app/build.gradle delete mode 100644 android/app/src/debug/AndroidManifest.xml delete mode 100644 android/app/src/main/AndroidManifest.xml delete mode 100644 android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/MainActivity.kt delete mode 100644 android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationStreamHandler.kt delete mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml delete mode 100644 android/app/src/main/res/values-night/styles.xml delete mode 100644 android/app/src/main/res/values/styles.xml delete mode 100644 android/app/src/profile/AndroidManifest.xml delete mode 100644 android/build.gradle delete mode 100644 android/gradle.properties delete mode 100644 android/settings.gradle create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/illusionman1212/lyricsgrabbr/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml rename {android/app => app}/src/main/ic_launcher-playstore.png (100%) create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/LGApp.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/MainActivity.kt rename {android/app/src/main/kotlin/com/illusionware/lyricsgrabbr => app/src/main/java/com/illusionman1212}/lyricsgrabbr/NotificationListener.kt (63%) create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/Routes.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/SpotifyPkceLoginActivityImpl.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/data/HomeRepository.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/data/LyricsRepository.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/data/SettingsPreferencesRepository.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/data/network/NetworkHelper.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/data/network/Requests.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/ForwardingPainter.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGAlertDialog.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGIconButton.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGRadioButton.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Home.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Lyrics.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Settings.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Color.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Type.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/utils/OkHttpExtensions.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/utils/Resources.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/viewmodels/HomeViewModel.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/viewmodels/LyricsViewModel.kt create mode 100644 app/src/main/java/com/illusionman1212/lyricsgrabbr/viewmodels/SettingsViewModel.kt rename {android/app => app}/src/main/res/drawable-anydpi-v24/ic_notification.xml (100%) rename {android/app => app}/src/main/res/drawable-hdpi/ic_notification.png (100%) rename {android/app => app}/src/main/res/drawable-mdpi/ic_notification.png (100%) rename {android/app/src/main/res/drawable => app/src/main/res/drawable-v24}/ic_launcher_foreground.xml (100%) rename {android/app => app}/src/main/res/drawable-xhdpi/ic_notification.png (100%) rename {android/app => app}/src/main/res/drawable-xxhdpi/ic_notification.png (100%) rename {android/app => app}/src/main/res/drawable/ic_launcher_background.xml (100%) create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml rename {android/app => app}/src/main/res/drawable/launch_background.xml (100%) create mode 100644 app/src/main/res/drawable/spotify.xml rename {android/app => app}/src/main/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename {android/app => app}/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) rename {android/app => app}/src/main/res/mipmap-hdpi/ic_launcher.png (100%) rename {android/app => app}/src/main/res/mipmap-hdpi/ic_launcher_round.png (100%) rename {android/app => app}/src/main/res/mipmap-mdpi/ic_launcher.png (100%) rename {android/app => app}/src/main/res/mipmap-mdpi/ic_launcher_round.png (100%) rename {android/app => app}/src/main/res/mipmap-xhdpi/ic_launcher.png (100%) rename {android/app => app}/src/main/res/mipmap-xhdpi/ic_launcher_round.png (100%) rename {android/app => app}/src/main/res/mipmap-xxhdpi/ic_launcher.png (100%) rename {android/app => app}/src/main/res/mipmap-xxhdpi/ic_launcher_round.png (100%) rename {android/app => app}/src/main/res/mipmap-xxxhdpi/ic_launcher.png (100%) rename {android/app => app}/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png (100%) create mode 100644 app/src/main/res/values-ar/strings.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/com/illusionman1212/lyricsgrabbr/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar rename {android/gradle => gradle}/wrapper/gradle-wrapper.properties (80%) create mode 100755 gradlew create mode 100644 gradlew.bat delete mode 100644 lib/lyrics.dart delete mode 100644 lib/main.dart delete mode 100644 lib/notification_listener.dart delete mode 100644 lib/search_result.dart delete mode 100644 lib/settings.dart delete mode 100644 lib/theme_provider.dart delete mode 100644 pubspec.lock delete mode 100644 pubspec.yaml create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 0fa6b67..0000000 --- a/.gitignore +++ /dev/null @@ -1,46 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -/build/ - -# Web related -lib/generated_plugin_registrant.dart - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index f1bc56b..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "3rdparty/flutter"] - path = 3rdparty/flutter - url = https://github.com/flutter/flutter diff --git a/.metadata b/.metadata deleted file mode 100644 index 05f4d5d..0000000 --- a/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: 097d3313d8e2c7f901932d63e537c1acefb87800 - channel: stable - -project_type: app diff --git a/3rdparty/flutter b/3rdparty/flutter deleted file mode 160000 index f468f33..0000000 --- a/3rdparty/flutter +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f468f3366c26a5092eb964a230ce7892fda8f2f8 diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index 61b6c4d..0000000 --- a/analysis_options.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore deleted file mode 100644 index 6f56801..0000000 --- a/android/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java - -# Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app -key.properties -**/*.keystore -**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index b0007eb..0000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,95 +0,0 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file("key.properties") -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -android { - compileSdkVersion flutter.compileSdkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - applicationId "com.illusionman1212.lyricsgrabbr" - minSdkVersion 23 - targetSdkVersion 31 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - signingConfigs { - release { - keyAlias keystoreProperties['keyAlias'] - keyPassword keystoreProperties['keyPassword'] - storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null - storePassword keystoreProperties['storePassword'] - } - } - - buildTypes { - release { - signingConfig signingConfigs.release - } - debug { - signingConfig signingConfigs.debug - } - } -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9' -} - -ext.abiCodes = ["x86_64": 1, "armeabi-v7a": 2, "arm64-v8a": 3] -import com.android.build.OutputFile -android.applicationVariants.all { variant -> - variant.outputs.each { output -> - def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI)) - if (abiVersionCode != null) { - output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode - } - } -} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index dbf0756..0000000 --- a/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 24a0987..0000000 --- a/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/MainActivity.kt b/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/MainActivity.kt deleted file mode 100644 index b09edb6..0000000 --- a/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/MainActivity.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.illusionman1212.lyricsgrabbr - -import android.content.Intent -import android.os.Bundle -import android.text.TextUtils -import android.util.Log -import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat -import io.flutter.embedding.android.FlutterActivity -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MethodChannel - - -class MainActivity: FlutterActivity() { - private var streamHandler : NotificationStreamHandler? = null - - companion object { - const val ACTION_NOTIFICATION_LISTENER_SETTINGS = "android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS" - private const val EVENT_CHANNEL_NAME = "notifications.eventChannel" - private const val METHOD_CHANNEL_NAME = "notifications/method" - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val methodChannel = MethodChannel(flutterEngine!!.dartExecutor.binaryMessenger, METHOD_CHANNEL_NAME) - methodChannel.setMethodCallHandler { call, result -> - when (call.method) { - "plugin.hasPermission" -> { - result.success(isPermissionGranted()) - } - "plugin.openPermissionSettings" -> { - result.success(openPermissionSettings()) - } - else -> result.notImplemented() - } - } - - val eventChannel = EventChannel(flutterEngine!!.dartExecutor.binaryMessenger, EVENT_CHANNEL_NAME) - streamHandler = NotificationStreamHandler(context) - eventChannel.setStreamHandler(streamHandler) - } - - override fun onDestroy() { - if (streamHandler?.receiver != null) { - context.unregisterReceiver(streamHandler?.receiver) - } - super.onDestroy() - } - - private fun isPermissionGranted(): Boolean { - val packageName = packageName - val pkgs = NotificationManagerCompat.getEnabledListenerPackages(context) - for (name in pkgs) { - if (TextUtils.equals(packageName, name)) { - return true - } - } - - return false - } - - private fun openPermissionSettings(): Boolean { - context.startActivity(Intent(ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) - return true - } -} diff --git a/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationStreamHandler.kt b/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationStreamHandler.kt deleted file mode 100644 index 7018ac0..0000000 --- a/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationStreamHandler.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.illusionman1212.lyricsgrabbr - -import io.flutter.plugin.common.EventChannel.StreamHandler -import io.flutter.plugin.common.EventChannel.EventSink -import android.content.* -import android.util.Log -import androidx.core.content.ContextCompat - -class NotificationStreamHandler(private val context: Context) : StreamHandler { - private var eventSink: EventSink? = null - var receiver: NotificationReceiver? = null - private var listenerIntent : Intent? = null - - // Called whenever the event channel is subscribed to in Flutter - override fun onListen(o: Any?, eventSink: EventSink?) { - this.eventSink = eventSink - - listenerIntent = Intent(context, NotificationListener::class.java) - listenerIntent?.action = NotificationListener.START_FOREGROUND_SERVICE_ACTION - - receiver = NotificationReceiver() - val intentFilter = IntentFilter() - intentFilter.addAction(NotificationListener.NOTIFICATION_INTENT) - context.registerReceiver(receiver, intentFilter) - - ContextCompat.startForegroundService(context, listenerIntent!!) - } - - // Called whenever the event channel subscription is cancelled in Flutter - override fun onCancel(o: Any?) { - eventSink = null - Log.d("onCancel", "subscription cancelled, stopping service") - } - - inner class NotificationReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val packageName = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_NAME) - val packageMessage = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_MESSAGE) - val packageText = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_TEXT) - val packagePlaybackState = intent.getIntExtra(NotificationListener.NOTIFICATION_PACKAGE_PLAYBACK_STATE, -1) - val packageDuration = intent.getLongExtra(NotificationListener.NOTIFICATION_PACKAGE_DURATION, -1) - val map = HashMap() - map["packageName"] = packageName!! - map["packageMessage"] = packageMessage!! - map["packageText"] = packageText!! - map["packagePlaybackState"] = packagePlaybackState - map["packageDuration"] = packageDuration - eventSink?.success(map) - } - } -} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index 35a759d..0000000 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 3db14bb..0000000 --- a/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml deleted file mode 100644 index fd32325..0000000 --- a/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index dbf0756..0000000 --- a/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index a1f42e7..0000000 --- a/android/build.gradle +++ /dev/null @@ -1,31 +0,0 @@ -buildscript { - ext.kotlin_version = '1.8.22' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.1.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/android/gradle.properties b/android/gradle.properties deleted file mode 100644 index c05efd1..0000000 --- a/android/gradle.properties +++ /dev/null @@ -1,16 +0,0 @@ -## For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -# Default value: -Xmx1024m -XX:MaxPermSize=256m -# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -# -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true -#Sun Feb 27 21:16:18 EET 2022 -org.gradle.jvmargs=-Xmx1024M -Dkotlin.daemon.jvm.options\="-Xmx1024M" -android.useAndroidX=true -android.enableJetifier=true diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index 44e62bc..0000000 --- a/android/settings.gradle +++ /dev/null @@ -1,11 +0,0 @@ -include ':app' - -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() - -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } - -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..8555a9b --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/androidTest/java/com/illusionman1212/lyricsgrabbr/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/illusionman1212/lyricsgrabbr/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..f85890d --- /dev/null +++ b/app/src/androidTest/java/com/illusionman1212/lyricsgrabbr/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..841a497 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png similarity index 100% rename from android/app/src/main/ic_launcher-playstore.png rename to app/src/main/ic_launcher-playstore.png diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/LGApp.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/LGApp.kt new file mode 100644 index 0000000..5b2564d --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/LGApp.kt @@ -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 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) + } +} diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/MainActivity.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/MainActivity.kt new file mode 100644 index 0000000..021fb0c --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/MainActivity.kt @@ -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 = 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!!, + ) } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationListener.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/NotificationListener.kt similarity index 63% rename from android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationListener.kt rename to app/src/main/java/com/illusionman1212/lyricsgrabbr/NotificationListener.kt index 4415a96..b30f9de 100644 --- a/android/app/src/main/kotlin/com/illusionware/lyricsgrabbr/lyricsgrabbr/NotificationListener.kt +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/NotificationListener.kt @@ -1,33 +1,33 @@ 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.os.Handler import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification -import android.util.Log 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 - -/** - * Notification listening service. Intercepts notifications if permission is given to do so. - */ -class NotificationListener : NotificationListenerService() { +class NotificationListener: NotificationListenerService() { private var notificationManager : NotificationManagerCompat? = null override fun onCreate() { super.onCreate() - Log.d("onCreate Service", "creating service") initNotificationChannels(this) notificationManager = NotificationManagerCompat.from(applicationContext) @@ -37,7 +37,6 @@ class NotificationListener : NotificationListenerService() { if (intent != null) { when (intent.action) { START_FOREGROUND_SERVICE_ACTION -> { - Log.d("START", "starting foreground service") startForegroundService() getCurrentNotifications(applicationContext) } @@ -60,31 +59,28 @@ class NotificationListener : NotificationListenerService() { } private fun getStopServiceNotificationAction(): PendingIntent { - val intent = Intent(MainActivity.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + 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() { - val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) - .setContentTitle("Service Running") - .setContentText("Play some songs on a supported player to get lyrics") - .setSmallIcon(R.drawable.ic_notification) - .setShowWhen(false) - .addAction(R.drawable.ic_notification, "Stop Service", getStopServiceNotificationAction()) - .build() + _isRunning.update { true } - notification.flags = Notification.FLAG_FOREGROUND_SERVICE - startForeground(NOTIFICATION_ID, notification) + startForeground(NOTIFICATION_ID, getInitialNotification()) } private fun stopForegroundService() { - stopForeground(true) + 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 < 26) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return } val notificationManager = @@ -105,74 +101,83 @@ class NotificationListener : NotificationListenerService() { } } + 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) { - // get the media session stuff - val mediaSessionToken = sbn.notification.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION) - if (mediaSessionToken == null) { + 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(Notification.EXTRA_MEDIA_SESSION) + ?: return - val mediaCtrl = mediaSessionToken.let { MediaController(context, it) } + 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 package name to set as title. - val packageName = sbn.packageName // Retrieve extra object from notification to extract payload. val extras = sbn.notification.extras - val packageMessage = extras?.getCharSequence(Notification.EXTRA_TEXT).toString() - val packageText = extras?.getCharSequence("android.title").toString() + 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, packageName) - intent.putExtra(NOTIFICATION_PACKAGE_MESSAGE, packageMessage) - intent.putExtra(NOTIFICATION_PACKAGE_TEXT, packageText) + 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) { - val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) - .setContentTitle("Service Running") - .setContentText("Play some songs on a supported player to get lyrics") - .setSmallIcon(R.drawable.ic_notification) - .setShowWhen(false) - .addAction(R.drawable.ic_notification, "Stop Service", getStopServiceNotificationAction()) - .build() - - notification.flags = Notification.FLAG_FOREGROUND_SERVICE - notificationManager?.notify(NOTIFICATION_ID, notification) + 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("Playing [${packageText}] By [${packageMessage}]") - .setContentText("Tap to get lyrics") + .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, "Stop Service", getStopServiceNotificationAction()) + .addAction(R.drawable.ic_notification, resources.getString(R.string.stop_service), getStopServiceNotificationAction()) .build() notification.flags = Notification.FLAG_FOREGROUND_SERVICE notificationManager?.notify(NOTIFICATION_ID, notification) } - sendBroadcast(intent) + 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_MESSAGE = "package_message" - const val NOTIFICATION_PACKAGE_TEXT = "package_text" + 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 = 12251999 + const val NOTIFICATION_ID = 1 const val NOTIFICATION_CHANNEL_ID = "Lyrics" const val START_FOREGROUND_SERVICE_ACTION = "START_SERVICE" } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/Routes.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/Routes.kt new file mode 100644 index 0000000..3e19227 --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/Routes.kt @@ -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") +} + diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/SpotifyPkceLoginActivityImpl.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/SpotifyPkceLoginActivityImpl.kt new file mode 100644 index 0000000..1d4cee6 --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/SpotifyPkceLoginActivityImpl.kt @@ -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 = 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/HomeRepository.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/HomeRepository.kt new file mode 100644 index 0000000..41d907a --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/HomeRepository.kt @@ -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 { + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/LyricsRepository.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/LyricsRepository.kt new file mode 100644 index 0000000..820cd21 --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/LyricsRepository.kt @@ -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, + 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 { + 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 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" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/SettingsPreferencesRepository.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/SettingsPreferencesRepository.kt new file mode 100644 index 0000000..32f9db0 --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/SettingsPreferencesRepository.kt @@ -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 +) { + 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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/network/NetworkHelper.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/network/NetworkHelper.kt new file mode 100644 index 0000000..c868d4c --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/network/NetworkHelper.kt @@ -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() } +} diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/network/Requests.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/network/Requests.kt new file mode 100644 index 0000000..5a29fc3 --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/data/network/Requests.kt @@ -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() +} diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/ForwardingPainter.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/ForwardingPainter.kt new file mode 100644 index 0000000..1e30dcf --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/ForwardingPainter.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGAlertDialog.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGAlertDialog.kt new file mode 100644 index 0000000..d5dcc96 --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGAlertDialog.kt @@ -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() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGIconButton.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGIconButton.kt new file mode 100644 index 0000000..6e6c38e --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGIconButton.kt @@ -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() +// } +// } +// } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGRadioButton.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGRadioButton.kt new file mode 100644 index 0000000..f1c713f --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/components/LGRadioButton.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Home.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Home.kt new file mode 100644 index 0000000..1127aa2 --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Home.kt @@ -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 +) { + 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, + 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 = {}, + ) +} diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Lyrics.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Lyrics.kt new file mode 100644 index 0000000..9295e24 --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Lyrics.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Settings.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Settings.kt new file mode 100644 index 0000000..d42e40a --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/screens/Settings.kt @@ -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 Activity.guardValidSpotifyApi( + classBackTo: Class, + 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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Color.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Color.kt new file mode 100644 index 0000000..b217ccd --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Theme.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Theme.kt new file mode 100644 index 0000000..3efdedd --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Theme.kt @@ -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, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Type.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Type.kt new file mode 100644 index 0000000..07867e9 --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/ui/theme/Type.kt @@ -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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/utils/OkHttpExtensions.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/utils/OkHttpExtensions.kt new file mode 100644 index 0000000..c4e40a4 --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/utils/OkHttpExtensions.kt @@ -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): 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") \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/utils/Resources.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/utils/Resources.kt new file mode 100644 index 0000000..5c1d602 --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/utils/Resources.kt @@ -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(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() +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/viewmodels/HomeViewModel.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/viewmodels/HomeViewModel.kt new file mode 100644 index 0000000..8a0de9e --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/viewmodels/HomeViewModel.kt @@ -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 { + 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 +) + +data class HomeState( + val isLoading: Boolean = false, + val results: List = 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/viewmodels/LyricsViewModel.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/viewmodels/LyricsViewModel.kt new file mode 100644 index 0000000..678230e --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/viewmodels/LyricsViewModel.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionman1212/lyricsgrabbr/viewmodels/SettingsViewModel.kt b/app/src/main/java/com/illusionman1212/lyricsgrabbr/viewmodels/SettingsViewModel.kt new file mode 100644 index 0000000..4402d73 --- /dev/null +++ b/app/src/main/java/com/illusionman1212/lyricsgrabbr/viewmodels/SettingsViewModel.kt @@ -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) + } + } + } +} diff --git a/android/app/src/main/res/drawable-anydpi-v24/ic_notification.xml b/app/src/main/res/drawable-anydpi-v24/ic_notification.xml similarity index 100% rename from android/app/src/main/res/drawable-anydpi-v24/ic_notification.xml rename to app/src/main/res/drawable-anydpi-v24/ic_notification.xml diff --git a/android/app/src/main/res/drawable-hdpi/ic_notification.png b/app/src/main/res/drawable-hdpi/ic_notification.png similarity index 100% rename from android/app/src/main/res/drawable-hdpi/ic_notification.png rename to app/src/main/res/drawable-hdpi/ic_notification.png diff --git a/android/app/src/main/res/drawable-mdpi/ic_notification.png b/app/src/main/res/drawable-mdpi/ic_notification.png similarity index 100% rename from android/app/src/main/res/drawable-mdpi/ic_notification.png rename to app/src/main/res/drawable-mdpi/ic_notification.png diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml similarity index 100% rename from android/app/src/main/res/drawable/ic_launcher_foreground.xml rename to app/src/main/res/drawable-v24/ic_launcher_foreground.xml diff --git a/android/app/src/main/res/drawable-xhdpi/ic_notification.png b/app/src/main/res/drawable-xhdpi/ic_notification.png similarity index 100% rename from android/app/src/main/res/drawable-xhdpi/ic_notification.png rename to app/src/main/res/drawable-xhdpi/ic_notification.png diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_notification.png b/app/src/main/res/drawable-xxhdpi/ic_notification.png similarity index 100% rename from android/app/src/main/res/drawable-xxhdpi/ic_notification.png rename to app/src/main/res/drawable-xxhdpi/ic_notification.png diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from android/app/src/main/res/drawable/ic_launcher_background.xml rename to app/src/main/res/drawable/ic_launcher_background.xml diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..262283d --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/app/src/main/res/drawable/launch_background.xml similarity index 100% rename from android/app/src/main/res/drawable/launch_background.xml rename to app/src/main/res/drawable/launch_background.xml diff --git a/app/src/main/res/drawable/spotify.xml b/app/src/main/res/drawable/spotify.xml new file mode 100644 index 0000000..1ba7ba6 --- /dev/null +++ b/app/src/main/res/drawable/spotify.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png rename to app/src/main/res/mipmap-hdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png rename to app/src/main/res/mipmap-mdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png rename to app/src/main/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png rename to app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..b8af128 --- /dev/null +++ b/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,52 @@ + + + Lyrics Grabbr + + + رجوع + إلغاء + تأكيد + لقد انتهت مهلة الطلب، حاول مرة أخرى لاحقًا + لقد حدث خطأ، حاول مرة أخرى لاحقاً + أعد المحاولة + عذرًا، لا يحتوي Spotify على كلمات لهذه الأغنية + + + لا يوجد مشغل مدعوم قيد التشغيل + فتح الإعدادات + إذن قراءة الإشعارات + هذا التطبيق يحتاج إذن قراءة الإشعارات للعمل بشكل صحيح. يرجى منح الإذن في شاشة الإعدادات التالية.\nإحذر أن الطريقة الوحيدة لإيقاف الخدمة هي إبطال إذن قراءة الإشعارات. + لم يتم العثور على نتائج + تم العثور على %1$d نتيجة + مشغل بواسطةgeniURL + افتح صفحة الأغنية + افتح صفحة الفنان + مطلوب إذن الإشعارات + يتطلب هذا التطبيق أذونات الإشعارات لبدء خدمة مقدمة بغرض الاستماع إلى إشعارات الموسيقى، ولا يمكن أن يعمل بدونها. + لم يتم منح إذن الإشعارات، لن يعمل التطبيق بشكل صحيح + منح الإذن + No song is playing on Spotify + + + الإعدادات + المظهر + مظهر التطبيق + نهار + ليلي + استخدم مظهر النظام + استخدم Spotify للكلمات + Spotify Client ID + خالي + يمكن لهذا التطبيق استخدام واجهة برمجة تطبيقات الويب الخاصة بـ Spotify للحصول على كلمات الأغاني. تحتاج إلى تقديم Spotify Client ID لاستخدام هذه الميزة. يمكنك الحصول على واحدة من هنا.\nلا يعمل هذا إلا إذا كنت تقوم بتشغيل الموسيقى من مشغل Spotify. + + + كلمات الأغنية + هذه الأغنية موسيقية + + + الخدمة قيد التشغيل + وقف الخدمة + قم بتشغيل بعض الأغاني على مشغل مدعوم للحصول على كلمات الأغاني + انقر للحصول على كلمات الأغاني + يتم الآن تشغيل %1$s لـ %2$s + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..e0e1a50 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,51 @@ + + Lyrics Grabbr + + + Go Back + Cancel + Confirm + Request has timed out, try again later + An error has occurred, try again later + Retry + Sorry, Spotify doesn\'t have lyrics for this song + + + No supported player is running + Open Settings + Notification Listening Permission + 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. + No results found + Found %1$d result(s) + Powered By geniURL + Open Song Page + Open Artist Page + Notification Permission Required + LyricsGrabbr requires notification permissions to start a foreground service for the purpose of listening to music notifications, and cannot function without it. + Notification permission is not granted, the app will not function properly + Grant Permission + No song is playing on Spotify + + + Settings + Appearance + Theme + Light + Dark + Use System Theme + Use Spotify for lyrics + Spotify Client ID + Not Set + 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 here.\nThis only works if you\'re playing music from the Spotify player. + + + Lyrics + This song is an instrumental + + + Service Running + Stop Service + Play some songs on a supported player to get lyrics + Tap to get lyrics + Now Playing %1$s By %2$s + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..35968ee --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/illusionman1212/lyricsgrabbr/ExampleUnitTest.kt b/app/src/test/java/com/illusionman1212/lyricsgrabbr/ExampleUnitTest.kt new file mode 100644 index 0000000..2d5aaa0 --- /dev/null +++ b/app/src/test/java/com/illusionman1212/lyricsgrabbr/ExampleUnitTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..55ea678 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id("com.android.application") version "8.2.1" apply false + id("org.jetbrains.kotlin.android") version "1.9.0" apply false +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..41d7819 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties similarity index 80% rename from android/gradle/wrapper/gradle-wrapper.properties rename to gradle/wrapper/gradle-wrapper.properties index 595fb86..9f59ffd 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Jun 23 08:50:38 CEST 2017 +#Tue Jan 09 20:42:38 EET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/lyrics.dart b/lib/lyrics.dart deleted file mode 100644 index dcbfee4..0000000 --- a/lib/lyrics.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:web_scraper/web_scraper.dart'; - -class LyricsPage extends StatefulWidget { - const LyricsPage({ - Key? key, - required this.title, - required this.artist, - required this.url, - }) : super(key: key); - - final String title; - final String artist; - final String url; - - @override - State createState() => _LyricsPageState(); -} - -class _LyricsPageState extends State { - bool fetching = false; - String? lyrics; - bool instrumental = false; - - @override - void initState() { - super.initState(); - initPlatformState(); - } - - Future initPlatformState() async { - setState(() => fetching = true); - - final webScraper = WebScraper('https://genius.com'); - - String path = widget.url.split('genius.com/')[1]; - String fullLyrics = ''; - if (await webScraper.loadWebPage('/$path?react=1')) { - webScraper - .loadFromString(webScraper.getPageContent().replaceAll('
', '\n')); - var element = webScraper - .getElement("div[data-lyrics-container]", ['data-lyrics-container']); - for (var section in element) { - fullLyrics += section['title'] + '\n'; - } - setState(() { - lyrics = fullLyrics; - instrumental = fullLyrics.isEmpty; - fetching = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - Text( - widget.artist, - style: const TextStyle( - fontSize: 13, - ), - ), - ], - ), - ), - body: Center( - child: fetching - ? const Column( - children: [ - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [CircularProgressIndicator(value: null)], - ), - ), - ], - ) - : instrumental - ? const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.music_note, size: 50), - SizedBox(width: 20, height: 20), - Text( - 'This Song is an Instrumental', - style: TextStyle( - fontSize: 20, - fontStyle: FontStyle.italic, - ), - ), - ], - ), - ) - : SizedBox( - width: MediaQuery.of(context).size.width, - child: SingleChildScrollView( - child: Container( - padding: const EdgeInsets.all(30), - child: SelectableText( - lyrics ?? '', - style: const TextStyle( - fontSize: 16, - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/main.dart b/lib/main.dart deleted file mode 100644 index caec3ab..0000000 --- a/lib/main.dart +++ /dev/null @@ -1,322 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'notification_listener.dart'; -import 'search_result.dart'; -import 'package:http/http.dart' as http; -import 'dart:convert' as convert; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'settings.dart'; -import 'theme_provider.dart'; - -Future main() async { - runApp(const LyricsGrabbr()); -} - -class LyricsGrabbr extends StatefulWidget { - const LyricsGrabbr({Key? key}) : super(key: key); - - @override - State createState() => _LyricsGrabbrState(); -} - -class _LyricsGrabbrState extends State { - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: SharedPreferences.getInstance(), - builder: (_, snapshot) { - final prefTheme = snapshot.data?.getString("theme"); - - var themeMode = ThemeMode.system; - if (prefTheme == AppTheme.Dark.name) { - themeMode = ThemeMode.dark; - } else if (prefTheme == AppTheme.Light.name) { - themeMode = ThemeMode.light; - } else if (prefTheme == AppTheme.System.name) { - themeMode = ThemeMode.system; - } - - return snapshot.hasData ? ChangeNotifierProvider( - create: (context) => ThemeProvider(themeMode), - builder: (context, _) { - final themeProvider = Provider.of(context); - - return MaterialApp( - title: 'Lyrics Grabbr', - theme: MyThemes.lightTheme, - darkTheme: MyThemes.darkTheme, - themeMode: themeProvider.themeMode, - home: const HomePage(title: 'Lyrics Grabbr'), - ); - } - ) : Container(); - } - ); - } -} - -class HomePage extends StatefulWidget { - const HomePage({Key? key, required this.title}) : super(key: key); - - final String title; - - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State with WidgetsBindingObserver { - late AndroidNotificationListener _notifications; - BuildContext? permissionDialogCtx; - NotificationEvent? lastNotification; - bool running = false; - bool searching = false; - final List searchResults = []; - - @override - void initState() { - WidgetsBinding.instance.addObserver(this); - super.initState(); - initPlatformState(); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) async { - if (state == AppLifecycleState.resumed) { - if (permissionDialogCtx == null) { - return; - } - - var perm = await AndroidNotificationListener.hasPermission; - - if (perm != null && perm) { - Navigator.pop(permissionDialogCtx!); - startListening(); - } - } - } - - void _showNotificationPermDialog() { - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext buildCtx) { - permissionDialogCtx = buildCtx; - - return WillPopScope( - onWillPop: () { - SystemNavigator.pop(); - return Future.value(false); - }, - child: AlertDialog( - title: const Text( - 'Notifications Access Permission', - style: TextStyle(fontWeight: FontWeight.bold), - ), - content: const Text( - 'This app requires permission to listen to notifications to function properly. Please grant permission in the next screen. BEWARE that the only way to stop the service is to revoke the notification listening permission.', - ), - actions: [ - TextButton( - onPressed: () async { - await AndroidNotificationListener - .openPermissionSettings(); - }, - child: const Text('OK')), - ], - ), - ); - }); - } - - Future initPlatformState() async { - var perm = await AndroidNotificationListener.hasPermission; - - if (perm != null && perm) { - startListening(); - } else { - _showNotificationPermDialog(); - } - } - - void onData(NotificationEvent event) { - if (event.playbackState == PlaybackState.STATE_INVALID) { - return; - } - - if (lastNotification?.songTitle != event.songTitle && - lastNotification?.artist != event.artist) { - searchLyrics(event.songTitle, event.artist); - - setState(() { - searchResults.clear(); - lastNotification = event; - }); - } - } - - void startListening() { - _notifications = AndroidNotificationListener(); - - _notifications.notificationStream.listen(onData); - setState(() => running = true); - } - - void searchLyrics( - String song, - String artist, - ) async { - setState(() { - searching = true; - }); - - // TODO: build an httpclient to reuse between requests. - final uri = Uri.https( - 'api.illusionman1212.com', - '/lyrics/search', - {'q': '$song $artist'}, - ); - - var response = await http.get(uri); - if (response.statusCode == 200) { - final jsonRes = convert.jsonDecode(response.body) as Map; - List results = jsonRes['all']; - - for (var result in results) { - var myResult = SearchResult.fromJson(result); - setState(() { - searchResults.add(myResult); - }); - } - - setState(() { - searching = false; - }); - } else { - // TODO: display error - print('error in http get'); - setState(() { - searching = false; - }); - } - } - - Widget _buildResults() { - return ListView.separated( - itemBuilder: (_, i) { - return searchResults[i]; - }, - itemCount: searchResults.length, - separatorBuilder: (BuildContext context, int index) { - return const Divider(height: 1); - }, - ); - } - - List _buildPage() { - if (lastNotification != null) { - return [ - Container( - padding: const EdgeInsets.all(20), - child: RichText( - text: TextSpan( - text: lastNotification?.songTitle, - style: TextStyle( - fontSize: 24.0, - color: Theme.of(context).textTheme.bodyLarge?.color, - fontWeight: FontWeight.w700, - ), - children: [ - const TextSpan( - text: '\n', - ), - TextSpan( - text: lastNotification?.artist, - style: TextStyle( - fontSize: 20.0, - color: Theme.of(context).textTheme.bodyMedium?.color, - ), - ) - ]), - textAlign: TextAlign.center, - ), - ), - Container( - child: searching - ? Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: const [CircularProgressIndicator(value: null)])) - : Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10), - margin: const EdgeInsets.symmetric(vertical: 10), - child: Text( - running - ? 'Found ${searchResults.length} result(s)' - : "", - style: TextStyle( - fontSize: 15, - color: Theme.of(context).textTheme.bodyText1?.color, - fontWeight: FontWeight.bold, - fontStyle: FontStyle.italic, - ), - ), - ), - Flexible(child: _buildResults()), - ], - ), - ), - ), - ]; - } - - return [ - const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Text("No supported player is running", - style: TextStyle(fontSize: 24)), - ), - ]; - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - centerTitle: true, - actions: [ - IconButton( - icon: const Icon(Icons.settings), - tooltip: "Settings", - splashRadius: 20, - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const SettingsPage())); - }, - ), - ], - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: _buildPage(), - ), - ), - ); - } -} diff --git a/lib/notification_listener.dart b/lib/notification_listener.dart deleted file mode 100644 index 1d954d3..0000000 --- a/lib/notification_listener.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'dart:async'; -import 'package:flutter/services.dart'; -import 'dart:io' show Platform; - -class NotificationException implements Exception { - final String _cause; - - NotificationException(this._cause); - - @override - String toString() { - return _cause; - } -} - -abstract class PlaybackState { - static const STATE_INVALID = -1; - static const STATE_PAUSED = 2; - static const STATE_PLAYING = 3; -} - -class NotificationEvent { - final String artist; - final String packageName; - final String songTitle; - final int playbackState; - final int duration; - - final DateTime timeStamp; - - const NotificationEvent( - {required this.packageName, - required this.artist, - required this.timeStamp, - required this.songTitle, - required this.playbackState, - required this.duration}); - - factory NotificationEvent.fromMap(Map map) { - DateTime time = DateTime.now(); - String name = map['packageName']; - String message = map['packageMessage']; - String text = map['packageText']; - int playbackState = map['packagePlaybackState']; - int duration = map['packageDuration']; - - return NotificationEvent( - packageName: name, - artist: message, - timeStamp: time, - songTitle: text, - playbackState: playbackState, - duration: duration); - } -} - -NotificationEvent _notificationEvent(dynamic data) { - return NotificationEvent.fromMap(data); -} - -class AndroidNotificationListener { - static const EventChannel _notificationEventChannel = - EventChannel('notifications.eventChannel'); - static const MethodChannel _methodChannel = - MethodChannel('notifications/method'); - - Stream get notificationStream { - if (Platform.isAndroid) { - Stream _notificationStream = _notificationEventChannel - .receiveBroadcastStream() - .map((event) => _notificationEvent(event)); - return _notificationStream; - } - throw NotificationException( - 'Notification API exclusively available on Android!'); - } - - static Future get hasPermission async { - return await _methodChannel.invokeMethod('plugin.hasPermission'); - } - - static Future openPermissionSettings() async { - return await _methodChannel.invokeMethod('plugin.openPermissionSettings'); - } -} diff --git a/lib/search_result.dart b/lib/search_result.dart deleted file mode 100644 index a700dc8..0000000 --- a/lib/search_result.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import 'lyrics.dart'; - -class SearchResult extends StatelessWidget { - const SearchResult( - {Key? key, - required this.title, - required this.artist, - required this.url, - required this.artistUrl, - required this.thumbnail, - required this.id}) - : super(key: key); - - final String title; - final String artist; - final Uri url; - final Uri artistUrl; - final String thumbnail; - final int id; - - SearchResult.fromJson(Map json, {Key? key}) - : title = json['meta']['title'], - artist = json['meta']['primaryArtist']['name'], - url = Uri.parse(json['url']), - artistUrl = Uri.parse(json['meta']['primaryArtist']['url']), - thumbnail = json['resources']['thumbnail'], - id = json['id'], - super(key: key); - - List _buildOptions() { - return [ - OptionItem( - title: "Open Artist Page in Genius", - icon: Icons.library_music, - onTap: () async { - if (!await launchUrl(artistUrl)) { - throw 'could not launch $artistUrl'; - } - }), - OptionItem( - title: "Open Song Page in Genius", - icon: Icons.music_note, - onTap: () async { - if (!await launchUrl(url)) { - throw 'could not launch $url'; - } - }) - ]; - } - - void _showOptionsBottomSheet(BuildContext context) { - showModalBottomSheet( - context: context, - builder: (context) { - return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: - (MediaQuery.of(context).size.height - kToolbarHeight) / 2, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: _buildOptions(), - ), - ); - }); - } - - @override - Widget build(BuildContext context) { - return Material( - child: InkWell( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => LyricsPage( - title: title, - artist: artist, - url: url.toString(), - ), - ), - ); - }, - onLongPress: () => _showOptionsBottomSheet(context), - child: Ink( - height: 75, - child: Row( - children: [ - Container( - margin: const EdgeInsets.only(right: 20), - child: Image.network( - thumbnail, - width: 75, - height: 75, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return const Center( - child: SizedBox( - width: 75, - height: 75, - child: Icon(Icons.broken_image, size: 40)), - ); - }, - ), - ), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - title, - style: TextStyle( - color: Theme.of(context).textTheme.bodyLarge?.color, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, - ), - Text( - artist, - style: TextStyle( - color: Theme.of(context).textTheme.titleMedium?.color, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} - -class OptionItem extends StatelessWidget { - final String title; - final IconData icon; - final void Function()? onTap; - - const OptionItem( - {Key? key, required this.title, required this.icon, this.onTap}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: Ink( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - Container( - margin: const EdgeInsets.only(right: 15), - child: Icon(icon), - ), - Text( - title, - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - ], - ), - ), - ); - } -} diff --git a/lib/settings.dart b/lib/settings.dart deleted file mode 100644 index 61ab279..0000000 --- a/lib/settings.dart +++ /dev/null @@ -1,188 +0,0 @@ -// ignore_for_file: constant_identifier_names - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'theme_provider.dart'; - -enum AppTheme { - Dark, - Light, - System -} - -class Setting extends StatelessWidget { - final String title; - final String? desc; - final void Function() onTap; - - const Setting({ - Key? key, - required this.title, - this.desc, - required this.onTap, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Material( - child: InkWell( - onTap: onTap, - child: Ink( - child: Container( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: const TextStyle(fontSize: 16)), - if (desc != null) - Text(desc!, - style: const TextStyle(color: Colors.grey)), - ]), - ), - ], - ), - ), - ), - ), - ); - } -} - -class SettingsPage extends StatefulWidget { - const SettingsPage({Key? key}) : super(key: key); - - @override - State createState() => _SettingsPageState(); -} - -class _SettingsPageState extends State { - final Future _prefs = SharedPreferences.getInstance(); - late SharedPreferences prefs; - String? activeTheme; - - @override - void initState() { - super.initState(); - initPlatformState(); - } - - Future initPlatformState() async { - prefs = await _prefs; - - setState(() { - activeTheme = prefs.getString("theme") ?? AppTheme.System.name; - }); - } - - Widget _buildSettingsGroup(String groupTitle, List items) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text(groupTitle, - style: const TextStyle(color: Colors.deepPurple)), - ), - ...items, - ], - ); - } - - List _buildSettingsList(BuildContext context) { - return [ - _buildSettingsGroup("Customization", [ - Setting( - title: "Theme", - desc: activeTheme, - onTap: () { - final provider = Provider.of(context, listen: false); - - showDialog( - context: context, - builder: (BuildContext buildCtx) { - return Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(2)), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 20), - child: Text("Theme", - style: Theme.of(context).textTheme.headline6), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - children: [ - RadioListTile( - value: AppTheme.System.name, - groupValue: activeTheme, - title: Text("Use System Default", style: Theme.of(context).textTheme.bodyText1), - onChanged: (String? value) => setState(() { - provider.changeTheme(ThemeMode.system); - activeTheme = AppTheme.System.name; - prefs.setString("theme", AppTheme.System.name); - Navigator.pop(context); - })), - RadioListTile( - value: AppTheme.Dark.name, - groupValue: activeTheme, - title: Text("Dark", style: Theme.of(context).textTheme.bodyText1), - onChanged: (String? value) => setState(() { - provider.changeTheme(ThemeMode.dark); - activeTheme = AppTheme.Dark.name; - prefs.setString("theme", AppTheme.Dark.name); - Navigator.pop(context); - })), - RadioListTile( - value: AppTheme.Light.name, - groupValue: activeTheme, - title: Text("Light", style: Theme.of(context).textTheme.bodyText1), - onChanged: (String? value) => setState(() { - provider.changeTheme(ThemeMode.light); - activeTheme = AppTheme.Light.name; - prefs.setString("theme", AppTheme.Light.name); - Navigator.pop(context); - })), - ], - ), - ), - ], - ), - ), - ); - }); - }) - ]) - ]; - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text("Settings"), - ), - body: ListView.separated( - itemBuilder: (_, i) { - return _buildSettingsList(context)[i]; - }, - separatorBuilder: (_, __) { - return const SizedBox(height: 15); - }, - itemCount: 1, - padding: const EdgeInsets.symmetric(vertical: 20), - ), - ); - } -} diff --git a/lib/theme_provider.dart b/lib/theme_provider.dart deleted file mode 100644 index c21211a..0000000 --- a/lib/theme_provider.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; - -class ThemeProvider extends ChangeNotifier { - ThemeMode themeMode; - - ThemeProvider(this.themeMode); - - bool get isDarkMode => themeMode == ThemeMode.dark; - - void changeTheme(ThemeMode mode) { - themeMode = mode; - - notifyListeners(); - } -} - -class MyThemes { - static final darkTheme = ThemeData( - primarySwatch: Colors.deepPurple, - dialogTheme: const DialogTheme(backgroundColor: Color(0xFF202020)), - unselectedWidgetColor: Colors.white, - canvasColor: Colors.black, - bottomSheetTheme: const BottomSheetThemeData(backgroundColor: Color(0xFF202020)), - dividerColor: Colors.white, - iconTheme: const IconThemeData(color: Colors.white), - textTheme: const TextTheme( - bodyText1: TextStyle(color: Colors.white, fontSize: 16), - bodyText2: TextStyle(color: Colors.white), - subtitle1: TextStyle(color: Colors.grey), - subtitle2: TextStyle(color: Colors.white), - headline6: TextStyle(color: Colors.white), - ), - ); - - static final lightTheme = ThemeData( - primarySwatch: Colors.purple, - dividerColor: Colors.black, - textTheme: const TextTheme( - bodyText1: TextStyle(fontSize: 16), - subtitle1: TextStyle(color: Colors.black54), - ), - ); -} diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index cd953f4..0000000 --- a/pubspec.lock +++ /dev/null @@ -1,442 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.dev" - source: hosted - version: "2.11.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - characters: - dependency: transitive - description: - name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - clock: - dependency: transitive - description: - name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf - url: "https://pub.dev" - source: hosted - version: "1.1.1" - collection: - dependency: transitive - description: - name: collection - sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" - url: "https://pub.dev" - source: hosted - version: "1.17.1" - csslib: - dependency: transitive - description: - name: csslib - sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - ffi: - dependency: transitive - description: - name: ffi - sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99 - url: "https://pub.dev" - source: hosted - version: "2.0.2" - file: - dependency: transitive - description: - name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" - url: "https://pub.dev" - source: hosted - version: "6.1.4" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "2118df84ef0c3ca93f96123a616ae8540879991b8b57af2f81b76a7ada49b2a4" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - html: - dependency: transitive - description: - name: html - sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" - url: "https://pub.dev" - source: hosted - version: "0.15.4" - http: - dependency: "direct main" - description: - name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" - url: "https://pub.dev" - source: hosted - version: "0.13.6" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - lints: - dependency: transitive - description: - name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - matcher: - dependency: transitive - description: - name: matcher - sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" - url: "https://pub.dev" - source: hosted - version: "0.12.15" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 - url: "https://pub.dev" - source: hosted - version: "0.2.0" - meta: - dependency: transitive - description: - name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - path: - dependency: transitive - description: - name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" - url: "https://pub.dev" - source: hosted - version: "1.8.3" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: ffbb8cc9ed2c9ec0e4b7a541e56fd79b138e8f47d2fb86815f15358a349b3b57 - url: "https://pub.dev" - source: hosted - version: "2.1.11" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "57585299a729335f1298b43245842678cb9f43a6310351b18fb577d6e33165ec" - url: "https://pub.dev" - source: hosted - version: "2.0.6" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: "1cb68ba4cd3a795033de62ba1b7b4564dace301f952de6bfb3cd91b202b6ee96" - url: "https://pub.dev" - source: hosted - version: "2.1.7" - platform: - dependency: transitive - description: - name: platform - sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - provider: - dependency: "direct main" - description: - name: provider - sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f - url: "https://pub.dev" - source: hosted - version: "6.0.5" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: "0344316c947ffeb3a529eac929e1978fcd37c26be4e8468628bac399365a3ca1" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: fe8401ec5b6dcd739a0fe9588802069e608c3fdbfd3c3c93e546cf2f90438076 - url: "https://pub.dev" - source: hosted - version: "2.2.0" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: f39696b83e844923b642ce9dd4bd31736c17e697f6731a5adf445b1274cf3cd4 - url: "https://pub.dev" - source: hosted - version: "2.3.2" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "71d6806d1449b0a9d4e85e0c7a917771e672a3d5dc61149cc9fac871115018e1" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "23b052f17a25b90ff2b61aad4cc962154da76fb62848a9ce088efe30d7c50ab1" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: "7347b194fb0bbeb4058e6a4e87ee70350b6b2b90f8ac5f8bd5b3a01548f6d33a" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: f95e6a43162bce43c9c3405f3eb6f39e5b5d11f65fab19196cf8225e2777624d - url: "https://pub.dev" - source: hosted - version: "2.3.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 - url: "https://pub.dev" - source: hosted - version: "1.9.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 - url: "https://pub.dev" - source: hosted - version: "1.11.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - test_api: - dependency: transitive - description: - name: test_api - sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb - url: "https://pub.dev" - source: hosted - version: "0.5.1" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c - url: "https://pub.dev" - source: hosted - version: "1.3.2" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - sha256: "781bd58a1eb16069412365c98597726cd8810ae27435f04b3b4d3a470bacd61e" - url: "https://pub.dev" - source: hosted - version: "6.1.12" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "15f5acbf0dce90146a0f5a2c4a002b1814a6303c4c5c075aa2623b2d16156f03" - url: "https://pub.dev" - source: hosted - version: "6.0.36" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "9af7ea73259886b92199f9e42c116072f05ff9bea2dcb339ab935dfc957392c2" - url: "https://pub.dev" - source: hosted - version: "6.1.4" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: "207f4ddda99b95b4d4868320a352d374b0b7e05eefad95a4a26f57da413443f5" - url: "https://pub.dev" - source: hosted - version: "3.0.5" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "1c4fdc0bfea61a70792ce97157e5cc17260f61abbe4f39354513f39ec6fd73b1" - url: "https://pub.dev" - source: hosted - version: "3.0.6" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: bfdfa402f1f3298637d71ca8ecfe840b4696698213d5346e9d12d4ab647ee2ea - url: "https://pub.dev" - source: hosted - version: "2.1.3" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: cc26720eefe98c1b71d85f9dc7ef0cada5132617046369d9dc296b3ecaa5cbb4 - url: "https://pub.dev" - source: hosted - version: "2.0.18" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "7967065dd2b5fccc18c653b97958fdf839c5478c28e767c61ee879f4e7882422" - url: "https://pub.dev" - source: hosted - version: "3.0.7" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - web_scraper: - dependency: "direct main" - description: - name: web_scraper - sha256: "34d41de25c1aaeeca2cd91c1d062ee3126f59db6c84ec32da17d44d082ecc177" - url: "https://pub.dev" - source: hosted - version: "0.1.4" - win32: - dependency: transitive - description: - name: win32 - sha256: dfdf0136e0aa7a1b474ea133e67cb0154a0acd2599c4f3ada3b49d38d38793ee - url: "https://pub.dev" - source: hosted - version: "5.0.5" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: e0b1147eec179d3911f1f19b59206448f78195ca1d20514134e10641b7d7fbff - url: "https://pub.dev" - source: hosted - version: "1.0.1" -sdks: - dart: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" diff --git a/pubspec.yaml b/pubspec.yaml deleted file mode 100644 index 5db48ab..0000000 --- a/pubspec.yaml +++ /dev/null @@ -1,90 +0,0 @@ -name: lyricsgrabbr -description: App that listens to your notifications and provides lyrics for the currently playing song. - -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.3.5+4 - -environment: - sdk: ">=3.0.0 <4.0.0" - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - - http: ^0.13.4 - url_launcher: ^6.0.20 - web_scraper: ^0.1.4 - shared_preferences: ^2.0.15 - provider: ^6.0.3 - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^2.0.2 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..cf28755 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Lyrics Grabbr" +include(":app") + \ No newline at end of file