33 Commits
Author SHA1 Message Date
Illusion 2f056d452e v1.1.1 release 2024-08-26 23:16:49 +02:00
Illusion a1c6008528 feat: added setting to configure custom API endpoint 2024-08-26 22:25:28 +02:00
Illusion 53c0afc780 show a one-time notice related to whitelisting apps 2024-08-24 15:10:08 +02:00
Illusion 8bbd3bd52b fix: prevent app crash by not parsing non-json responses 2024-08-24 14:17:51 +02:00
Illusion 2063673a0d add german language in the config and gradle files 2024-07-27 22:06:35 +02:00
Sv443andIllusion 88866f61c8 feat: fastlane metadata chungus in german 2024-07-27 22:06:35 +02:00
Sv443andIllusion 773e0fa657 feat: german translation 2024-07-27 22:06:35 +02:00
Illusion 78a97cdc41 v1.1.0 release 2024-07-18 01:07:33 +02:00
Illusion d2b4cbb535 feat: setting to keep screen on 2024-07-16 06:50:36 +02:00
Illusion 6af9de24f4 whitelist search 2024-07-14 06:54:41 +02:00
Illusion a0baf0f476 feat: whitelist for notification listening 2024-07-14 06:54:41 +02:00
Illusion edd5b24d92 feat: settings option to change the app language 2024-07-12 02:15:33 +02:00
Illusion 7d07433e95 remove spotify as lyrics source
In order to stay on f-droid spotify has to be removed. Doing different flavors is not worth it in my opinion.
2024-07-12 02:15:33 +02:00
Illusion 841629c6e3 gitignore 2024-01-19 20:47:36 +02:00
Illusion c028c23a8c fdroid changelog and new screenshots 2024-01-19 20:47:36 +02:00
Illusion 52634a98e4 build: split apks per ABI when building 2024-01-19 20:47:36 +02:00
Illusion f5afb3b25c fixes
- added instructions on how to use the "Spotify for lyrics" feature in
  the README
- added ABI splits in preparation for the f-droid relase
- removed the skrape.it dependency
- fixed bug where the "This song is an instrumental" screen always shows
  after the first time of fetching lyrics from spotify
- properly position title and rotate navigation button icon on topbar for RTL
  languages
- show errors on the lyrics screen
- version 1.0.0
2024-01-19 20:47:36 +02:00
Illusion 92c610a761 fix receiver not being started on subsequent app launches 2024-01-19 20:47:36 +02:00
Illusion 0069f62115 rewrite the app in native kotlin with compose 2024-01-19 20:47:36 +02:00
Illusion a40cf85925 chore: update flutter submodule and bump version to 0.3.5 2023-07-24 21:41:12 +03:00
Illusion 1d6e533106 fix: show indicator when a song is an instrumental 2023-07-24 21:41:12 +03:00
PoussinouandIllusion af9d9c5569 Update README.md 2023-07-18 10:36:56 +03:00
Illusion 1417eef23c make lyrics selectable and upgrade sdk version and deps 2023-07-17 01:41:00 +03:00
Illusion cd39204f6a bump version to 0.3.4 2023-02-06 22:33:22 +02:00
Illusion edc1f3a88b chore: change abi versioning 2023-02-06 22:30:46 +02:00
Illusion 0d96d906a4 chore: v0.3.3
* updated dependencies and removed unused ones
* added flutter as a submodule for easier f-droid updates
2023-02-05 18:51:22 +02:00
Illusion 55025d6d11 chore: short description under 80 chars 2023-02-05 07:10:28 +02:00
Illusion 3935faffc7 chore: correct license in README 2023-02-05 05:51:43 +02:00
Illusion 20c2862ea5 chore: fastlane for f-droid 2023-02-05 05:22:04 +02:00
Illusion 99b1035638 chore: sign app with release keys and change appId 2023-02-05 00:52:02 +02:00
Illusion e69b5e6848 fix: move to new api domain 2022-10-02 00:32:36 +03:00
Illusion 42077e17ee feat/fix: themes with some fixes 2022-08-07 06:26:54 +03:00
Illusion 55d26abaae chore: update readme 2022-08-06 02:42:48 +03:00
109 changed files with 3996 additions and 1617 deletions
+98 -36
View File
@@ -1,46 +1,108 @@
# Miscellaneous
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/build
/captures
.externalNativeBuild
.cxx
.idea
Release*
keystore.properties
output.json
# Built application files
*.apk
*.ap_
# Files for the Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# Intellij
*.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/
# Keystore files
*.jks
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# =========================
# Operating System Files
# =========================
# Web related
lib/generated_plugin_registrant.dart
# OSX
# =========================
# Symbolication related
app.*.symbols
.DS_Store
.AppleDouble
.LSOverride
# Obfuscation related
app.*.map.json
# Thumbnails
._*
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Windows
# =========================
# Windows image file caches
Thumbs.db
ehthumbs.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msm
*.msp
# Windows shortcuts
*.lnk
-10
View File
@@ -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
+10 -1
View File
@@ -1,3 +1,12 @@
# lyricsgrabbr
App that listens to your notifications and provides lyrics for the currently playing song.
App to listen to Spotify notifications and provide lyrics for currently playing song.
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.illusionman1212.lyricsgrabbr/)
## Supported players
Any player that uses the MediaSession API _should_ be supported
## License
The project is licensed under the GPL3 license while the assets (icon) are licensed under the creative commons license.
-29
View File
@@ -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
-13
View File
@@ -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
-68
View File
@@ -1,68 +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'
}
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.illusionware.lyricsgrabbr"
minSdkVersion 23
targetSdkVersion 31
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
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'
}
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.illusionware.lyricsgrabbr">
<!-- Flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
-54
View File
@@ -1,54 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.illusionware.lyricsgrabbr">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
</queries>
<application
android:label="Lyrics Grabbr"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:label="Lyrics Grabbr Notifications Listener"
android:name=".NotificationListener"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
@@ -1,67 +0,0 @@
package com.illusionware.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
}
}
@@ -1,51 +0,0 @@
package com.illusionware.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<String, Any>()
map["packageName"] = packageName!!
map["packageMessage"] = packageMessage!!
map["packageText"] = packageText!!
map["packagePlaybackState"] = packagePlaybackState
map["packageDuration"] = packageDuration
eventSink?.success(map)
}
}
}
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:opacity="opaque">
<!-- The background color -->
<item android:drawable="@drawable/ic_launcher_background"/>
<!-- App logo -->
<item
android:drawable="@drawable/ic_launcher_foreground"
android:gravity="center"
android:width="400dp"
android:height="400dp" />
</layer-list>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.illusionware.lyricsgrabbr">
<!-- Flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
-31
View File
@@ -1,31 +0,0 @@
buildscript {
ext.kotlin_version = '1.6.10'
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
}
-16
View File
@@ -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
-11
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
/build
+140
View File
@@ -0,0 +1,140 @@
import java.io.FileInputStream
import java.util.Properties
import com.android.build.api.variant.FilterConfiguration.FilterType.ABI
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()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.illusionman1212.lyricsgrabbr"
compileSdk = 34
defaultConfig {
applicationId = "com.illusionman1212.lyricsgrabbr"
minSdk = 23
targetSdk = 34
versionCode = 8
versionName = "1.1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
signingConfig = signingConfigs.getByName("debug")
// https://developer.android.com/guide/topics/resources/app-languages#gradle-config
resourceConfigurations.plus(listOf("en", "ar", "de"))
}
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")
// https://developer.android.com/build/configure-apk-splits#configure-abi-split
splits {
abi {
// Enables building multiple APKs per ABI.
isEnable = true
// By default all ABIs are included, so use reset() and include to specify that we only
// want APKs for armeabi-v7a, arm64-v8a, x86 and x86_64.
// Resets the list of ABIs that Gradle should create APKs for to none.
reset()
include("x86_64", "x86", "armeabi-v7a", "arm64-v8a")
isUniversalApk = true
}
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
androidComponents {
val abiVersionCodes = mapOf(
"x86_64" to 1,
"x86" to 2,
"armeabi-v7a" to 3,
"arm64-v8a" to 4,
)
onVariants { variant ->
// Assigns a different version code for each output APK
// other than the universal APK.
variant.outputs.forEach { output ->
val name = output.filters.find { it.filterType == ABI }?.identifier
// Stores the value of abiCodes that is associated with the ABI for this variant.
val abiCode = abiVersionCodes[name] ?: 0
// Assigns the new version code to output.versionCode, which changes the version code
// for only the output APK, not for the variant itself.
output.versionCode.set((output.versionCode.get() ?: 0) * 10 + abiCode)
}
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.3")
implementation("androidx.activity:activity-compose:1.9.0")
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3:1.3.0-beta04")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.compose.material:material-icons-extended:1.6.8")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.navigation:navigation-compose:2.7.7")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation("io.coil-kt:coil:2.6.0")
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("org.jsoup:jsoup:1.17.2")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2024.06.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
+23
View File
@@ -0,0 +1,23 @@
# 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
-dontwarn org.slf4j.impl.StaticLoggerBinder
@@ -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)
}
}
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:name=".LGApp"
android:allowBackup="true"
android:usesCleartextTraffic="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:localeConfig="@xml/locales_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LyricsGrabbr"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation|locale"
android:exported="true"
android:theme="@style/Theme.LyricsGrabbr.Splash"
>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:label="Lyrics Grabbr Notifications Listener"
android:name=".NotificationListener"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:foregroundServiceType="specialUse"
android:exported="false">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
<property
android:name="android.app.NOTIFICATION_LISTENER"
android:value="Media playback notification listener service" />
</service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false"
>
<meta-data
android:name="autoStoreLocales"
android:value="true"
/>
</service>
</application>
</manifest>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,28 @@
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.illusionman1212.lyricsgrabbr.data.HomeRepository
import com.illusionman1212.lyricsgrabbr.data.LyricsRepository
import com.illusionman1212.lyricsgrabbr.data.SettingsPreferencesRepository
private const val PREFERENCES_NAME = "preferences"
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = PREFERENCES_NAME
)
class LGApp: Application() {
lateinit var settingsPreferencesRepository: SettingsPreferencesRepository
lateinit var homeRepository: HomeRepository
lateinit var lyricsRepository: LyricsRepository
override fun onCreate() {
super.onCreate()
settingsPreferencesRepository = SettingsPreferencesRepository(dataStore)
homeRepository = HomeRepository(applicationContext)
lyricsRepository = LyricsRepository()
}
}
@@ -0,0 +1,201 @@
package com.illusionman1212.lyricsgrabbr
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.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.ui.screens.WhitelistPage
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,
)
class MainActivity : AppCompatActivity() {
private var lastNotification: MutableStateFlow<NotificationEvent?> = MutableStateFlow(null)
private var isReceiverRunning = false
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
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 {
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,
settingsViewModel = settingsViewModel
)
}
composable(Routes.Settings.route) {
SettingsPage(
goBack = {
navController.popBackStack()
},
navigateToWhitelist = {
navController.navigate(Routes.Whitelist.route)
},
viewModel = settingsViewModel,
)
}
composable(Routes.Whitelist.route) {
WhitelistPage(
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() {
if (!isPermissionGranted() || isReceiverRunning) {
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)
isReceiverRunning = true
}
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 notifPackageName = intent.getStringExtra(NotificationListener.NOTIFICATION_PACKAGE_NAME)
lastNotification.update { NotificationEvent(
title = songTitle!!,
artist = artist!!,
playbackState = playbackState,
packageName = notifPackageName!!,
) }
}
}
}
@@ -1,43 +1,54 @@
package com.illusionware.lyricsgrabbr
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.core.os.BundleCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.illusionman1212.lyricsgrabbr.data.SettingsPreferencesRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* Notification listening service. Intercepts notifications if permission is given to do so.
*/
class NotificationListener: NotificationListenerService() {
private var notificationManager : NotificationManagerCompat? = null
private lateinit var settingsPrefsRepo: SettingsPreferencesRepository
private val serviceScope = CoroutineScope(Dispatchers.IO)
override fun onCreate() {
super.onCreate()
Log.d("onCreate Service", "creating service")
initNotificationChannels(this)
notificationManager = NotificationManagerCompat.from(applicationContext)
settingsPrefsRepo = (application as LGApp).settingsPreferencesRepository
}
override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null) {
when (intent.action) {
START_FOREGROUND_SERVICE_ACTION -> {
Log.d("START", "starting foreground service")
startForegroundService()
getCurrentNotifications(applicationContext)
}
@@ -56,35 +67,33 @@ class NotificationListener : NotificationListenerService() {
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
serviceScope.launch {
val whitelist = settingsPrefsRepo.preferences.first().whitelist
if (sbn.packageName !in whitelist) return@launch
sendNotificationBroadcast(sbn, applicationContext)
}
}
private fun getStopServiceNotificationAction(): PendingIntent {
val intent = Intent(MainActivity.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
return pendingIntent
val intent = Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
}
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()
notification.flags = Notification.FLAG_FOREGROUND_SERVICE
startForeground(NOTIFICATION_ID, notification)
startForeground(NOTIFICATION_ID, getInitialNotification())
}
private fun stopForegroundService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
stopForeground(true)
}
stopSelf()
}
private fun initNotificationChannels(context: Context) {
if (Build.VERSION.SDK_INT < 26) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val notificationManager =
@@ -100,78 +109,84 @@ class NotificationListener : NotificationListenerService() {
private fun getCurrentNotifications(context: Context) {
val sbns = activeNotifications
serviceScope.launch {
val whitelist = settingsPrefsRepo.preferences.first().whitelist
for (sbn in sbns) {
if (sbn.packageName !in whitelist) continue
sendNotificationBroadcast(sbn, context)
}
}
private fun sendNotificationBroadcast(sbn: StatusBarNotification, context: Context) {
// get the media session stuff
val mediaSessionToken = sbn.notification.extras.getParcelable<MediaSession.Token>(Notification.EXTRA_MEDIA_SESSION)
if (mediaSessionToken == null) {
return
}
val mediaCtrl = mediaSessionToken.let { MediaController(context, it) }
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()
// 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_DURATION, duration)
intent.putExtra(NOTIFICATION_PACKAGE_PLAYBACK_STATE, playbackState)
if (playbackState == PlaybackState.STATE_PAUSED) {
private fun getInitialNotification(): Notification {
val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("Service Running")
.setContentText("Play some songs on a supported player to get lyrics")
.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, "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)
return notification
}
private fun sendNotificationBroadcast(sbn: StatusBarNotification, context: Context) {
if (ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED) {
return
}
val mediaSessionToken = BundleCompat.getParcelable(sbn.notification.extras, Notification.EXTRA_MEDIA_SESSION, MediaSession.Token::class.java)
?: return
val mediaCtrl = MediaController(context, mediaSessionToken)
val playbackState = mediaCtrl.playbackState?.state
val albumArt = mediaCtrl.metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
// Retrieve extra object from notification to extract payload.
val extras = sbn.notification.extras
val packageArtist = extras?.getCharSequence(Notification.EXTRA_TEXT).toString()
val packageSongTitle = extras?.getCharSequence("android.title").toString()
// Pass data from one activity to another.
val intent = Intent(NOTIFICATION_INTENT)
intent.putExtra(NOTIFICATION_PACKAGE_NAME, sbn.packageName)
intent.putExtra(NOTIFICATION_PACKAGE_ARTIST, packageArtist)
intent.putExtra(NOTIFICATION_PACKAGE_SONG_TITLE, packageSongTitle)
intent.putExtra(NOTIFICATION_PACKAGE_PLAYBACK_STATE, playbackState)
if (playbackState == PlaybackState.STATE_PAUSED) {
notificationManager?.notify(NOTIFICATION_ID, getInitialNotification())
} else {
val notificationIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("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 {
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"
}
@@ -0,0 +1,9 @@
package com.illusionman1212.lyricsgrabbr
sealed class Routes(val route: String) {
data object Home : Routes("home")
data object Lyrics : Routes("lyrics")
data object Settings : Routes("settings")
data object Whitelist : Routes("settings/whitelist")
}
@@ -0,0 +1,81 @@
package com.illusionman1212.lyricsgrabbr.data
import android.content.Context
import android.net.Uri
import android.util.Log
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 org.jsoup.SerializationException
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(lContext: Context, baseUrl: String, song: String, artist: String): Pair<String?, Boolean> {
val url = Uri.parse(baseUrl)
.buildUpon()
.appendPath("search")
.appendQueryParameter("q", "$song $artist")
.appendQueryParameter("disableFuzzy", "true")
.build()
try {
val res = networkHelper.client.newCall(
GET(url.toString())
).await()
if (!res.isSuccessful) {
if (res.code >= 500) {
return Pair(lContext.resources.getString(R.string.internal_server_error), false)
}
if (res.body?.contentType()?.type != "text" && res.body?.contentType()?.subtype != "json") {
return Pair(lContext.resources.getString(R.string.invalid_json), false)
}
try {
val errRes = json.decodeFromString(ErrorResponse.serializer(), res.body!!.string())
// matches is only null if there's an error. otherwise it's 0 when there are no results
if (errRes.matches == null) {
return Pair(lContext.resources.getString(R.string.generic_error), false)
}
} catch (e: IllegalArgumentException) {
return Pair(lContext.resources.getString(R.string.failed_to_map_json), false)
}
return Pair(null, false)
}
return Pair(res.body?.string(), true)
} catch (e: IOException) {
if (e.cause is SocketTimeoutException) {
return Pair(lContext.resources.getString(R.string.timeout), false)
}
return Pair(e.message, false)
}
}
}
@@ -0,0 +1,53 @@
package com.illusionman1212.lyricsgrabbr.data
import android.net.Uri
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
class LyricsRepository {
fun fetchLyricsFromGenius(url: Uri): String {
val doc = Jsoup.connect(url.toString()).userAgent(USER_AGENT).get()
val divs = doc.select("div[data-lyrics-container]")
val lyrics = StringUtil.borrowBuilder()
divs.forEach {
it.traverse(object : NodeVisitor {
override fun head(node: Node, depth: Int) {
if (node is TextNode) {
lyrics.append(node.text().trim())
// append space here to get proper spacing when there's a <i> tag or other inline text tags
lyrics.append(" ")
} else if (node is Element) {
if (lyrics.isNotEmpty() && node.normalName() == "br") {
lyrics.append("\n")
}
}
}
override fun tail(node: Node, depth: Int) {
// since we append a space after every text node, we only append a newline if
// the last char isn't a space
val lastCharIsWhitespace = lyrics.isNotEmpty() && lyrics[lyrics.length - 1] == ' '
if (node is Element) {
if (node.isBlock && node.nextSibling() is TextNode && !lastCharIsWhitespace) {
lyrics.append("\n")
}
}
}
})
lyrics.append("\n")
}
return lyrics.toString().replace("\n\n\n", "\n\n")
}
companion object {
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.3"
}
}
@@ -0,0 +1,84 @@
package com.illusionman1212.lyricsgrabbr.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.map
enum class Theme {
LIGHT,
DARK,
SYSTEM,
}
data class SettingsPreferences(
val appTheme: Int = Theme.SYSTEM.ordinal,
val whitelist: Set<String> = emptySet(),
val keepScreenOn: Boolean = false,
val whitelistPromptSeen: Boolean = false,
val geniURLbaseURL: String = "https://api.illusionman1212.com/lyrics",
)
class SettingsPreferencesRepository(
private val dataStore: DataStore<Preferences>
) {
companion object {
private val APP_THEME = intPreferencesKey("app_theme")
private val WHITELIST = stringSetPreferencesKey("apps_whitelist")
private val KEEP_SCREEN_ON = booleanPreferencesKey("keep_screen_on")
private val WHITELIST_PROMPT_SEEN = booleanPreferencesKey("whitelist_prompt_seen")
private val GENIURL_BASE_URL = stringPreferencesKey("geniurl_base_url")
}
val preferences = dataStore.data.map { mapSettingsPreferences(it) }
suspend fun setTheme(theme: Theme) {
dataStore.edit { prefs ->
prefs[APP_THEME] = theme.ordinal
}
}
suspend fun addToWhitelist(packageName: String) {
dataStore.edit { prefs ->
prefs[WHITELIST] = (prefs[WHITELIST]?.toMutableSet() ?: emptySet()) + packageName
}
}
suspend fun toggleKeepScreenOn() {
dataStore.edit { prefs ->
prefs[KEEP_SCREEN_ON] = !(prefs[KEEP_SCREEN_ON] ?: false)
}
}
suspend fun removeFromWhitelist(packageName: String) {
dataStore.edit { prefs ->
prefs[WHITELIST] = (prefs[WHITELIST]?.toMutableSet() ?: emptySet()) - packageName
}
}
suspend fun discardWhitelistPrompt() {
dataStore.edit { prefs ->
prefs[WHITELIST_PROMPT_SEEN] = true
}
}
suspend fun setGeniURLBaseURL(url: String) {
dataStore.edit { prefs ->
prefs[GENIURL_BASE_URL] = url
}
}
private fun mapSettingsPreferences(prefs: Preferences): SettingsPreferences {
return SettingsPreferences(
prefs[APP_THEME] ?: Theme.SYSTEM.ordinal,
prefs[WHITELIST] ?: emptySet(),
prefs[KEEP_SCREEN_ON] ?: false,
prefs[WHITELIST_PROMPT_SEEN] ?: false,
prefs[GENIURL_BASE_URL] ?: "https://api.illusionman1212.com/lyrics",
)
}
}
@@ -0,0 +1,24 @@
package com.illusionman1212.lyricsgrabbr.data.network
import android.content.Context
import okhttp3.Cache
import okhttp3.OkHttpClient
import java.io.File
import java.util.concurrent.TimeUnit
class NetworkHelper(context: Context) {
private val cacheDir = File(context.cacheDir, "network_cache")
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
private val baseClientBuilder: OkHttpClient.Builder
get() {
val builder = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.callTimeout(2, TimeUnit.MINUTES)
return builder
}
val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
}
@@ -0,0 +1,31 @@
package com.illusionman1212.lyricsgrabbr.data.network
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import java.util.concurrent.TimeUnit
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, TimeUnit.MINUTES).build()
private val DEFAULT_HEADERS = Headers.Builder().build()
fun GET(
url: String,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return GET(url.toHttpUrl(), headers, cache)
}
fun GET(
url: HttpUrl,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
.headers(headers)
.cacheControl(cache)
.build()
}
@@ -0,0 +1,77 @@
package com.illusionman1212.lyricsgrabbr.ui.components
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.withSave
const val DefaultAlpha = 1f
fun forwardingPainter(
painter: Painter,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
size: Size,
onDraw: DrawScope.(ForwardingDrawInfo) -> Unit = DefaultOnDraw,
): Painter = ForwardingPainter(painter, alpha, colorFilter, size, onDraw)
data class ForwardingDrawInfo(
val painter: Painter,
val alpha: Float,
val colorFilter: ColorFilter?,
val size: Size,
)
private class ForwardingPainter(
private val painter: Painter,
private var alpha: Float,
private var colorFilter: ColorFilter?,
private val size: Size,
private val onDraw: DrawScope.(ForwardingDrawInfo) -> Unit,
) : Painter() {
private var info = newInfo()
override val intrinsicSize get() = painter.intrinsicSize
override fun applyAlpha(alpha: Float): Boolean {
if (alpha != DefaultAlpha) {
this.alpha = alpha
this.info = newInfo()
}
return true
}
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
if (colorFilter != null) {
this.colorFilter = colorFilter
this.info = newInfo()
}
return true
}
override fun DrawScope.onDraw() = onDraw(info)
private fun newInfo() = ForwardingDrawInfo(painter, alpha, colorFilter, size)
}
private val DefaultOnDraw: DrawScope.(ForwardingDrawInfo) -> Unit = { info ->
val size = info.size
drawIntoCanvas {
val width = it.nativeCanvas.width.toFloat()
val height = it.nativeCanvas.height.toFloat()
val left = (width - size.width) / 2f
val top = (height - size.height) / 2f
it.withSave {
it.translate(left, top)
with(info.painter) {
draw(size, info.alpha, info.colorFilter)
}
}
}
}
@@ -0,0 +1,54 @@
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.BasicAlertDialog
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
) {
BasicAlertDialog(
onDismissRequest = onDismiss,
properties = properties
) {
Surface(
modifier = Modifier.wrapContentHeight().wrapContentWidth(),
shape = MaterialTheme.shapes.large,
tonalElevation = AlertDialogDefaults.TonalElevation
) {
Column(
modifier = modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = title, style = Typography.titleLarge)
content()
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
buttons()
}
}
}
}
}
@@ -0,0 +1,78 @@
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.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LGIconButton(
tooltip: String,
onClick: () -> Unit,
badge: (@Composable BoxScope.() -> Unit)? = null,
content: @Composable () -> Unit
) {
val tooltipState = rememberTooltipState()
Layout(
content = {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
Text(text = tooltip)
},
state = tooltipState,
) {
IconButton(
onClick = onClick,
content = content
)
}
Box(Modifier.layoutId("badge")) {
badge?.let { it() }
}
}
) { measurables, constraints ->
val iconPlaceable = measurables[0].measure(constraints)
val badgePlaceable = measurables.firstOrNull { it.layoutId == "badge" }?.measure(constraints)
layout(iconPlaceable.width, iconPlaceable.height) {
iconPlaceable.placeRelative(0, 0)
badgePlaceable?.placeRelative(
x = iconPlaceable.width - badgePlaceable.width - 16,
y = 16,
)
}
}
// PlainTooltipBox(tooltip = {
// Text(text = tooltip)
// }) {
// if (badge != null) {
// IconButton(
// onClick = onClick,
// Modifier.tooltipAnchor(),
// ) {
// BadgedBox(badge = badge) {
// content()
// }
// }
// } else {
// IconButton(
// onClick = onClick,
// Modifier.tooltipAnchor(),
// ) {
// content()
// }
// }
// }
}
@@ -0,0 +1,28 @@
package com.illusionman1212.lyricsgrabbr.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun LGRadioButton(text: String, onClick: () -> Unit, selected: Boolean) {
Box (Modifier.clickable { onClick() }.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = selected, onClick = onClick)
Text(text = text)
}
}
}
@Composable
@Preview(showBackground = true)
fun LGRadioButtonPreview() {
LGRadioButton(text = "Radio Button", onClick = {}, selected = true)
}
@@ -0,0 +1,568 @@
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.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.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
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.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.LocalLifecycleOwner
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.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,
),
lyricsViewModel: LyricsViewModel,
settingsViewModel: SettingsViewModel,
lastNotification: MutableStateFlow<NotificationEvent?>
) {
val context = LocalContext.current
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()
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
var notificationPermission by remember { mutableStateOf(
ActivityCompat.checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED
) }
var shouldShowNotificationPermissionRationale by remember { mutableStateOf(
ActivityCompat.shouldShowRequestPermissionRationale(
context as 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
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) {
homeViewModel.setSong(song)
homeViewModel.makeRequestToGenius(context, 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)
)
}
} else if (!settingsState.whitelistPromptSeen) {
LGAlertDialog(
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
),
onDismiss = { settingsViewModel.discardWhitelistPrompt() },
title = stringResource(id = R.string.whitelist_apps),
buttons = {
TextButton(
onClick = {
settingsViewModel.discardWhitelistPrompt()
}
) {
Text(stringResource(id = R.string.understood))
}
}
) {
Text(
text = stringResource(R.string.whitelist_prompt_desc),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
Column(
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,
textAlign = TextAlign.Center,
)
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(context, 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)
},
)
}
}
}
}
}
Column(
Modifier.height(36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val linkColor = MaterialTheme.colorScheme.surfaceTint
val text = annotatedStringResource(id = R.string.powered_by) {
when (it.key) {
"href" -> SpanStyle(color = linkColor)
else -> null
}
}
val textColor = LocalContentColor.current
val uriHandler = LocalUriHandler.current
ClickableText(
text,
onClick = { offset ->
text.getStringAnnotations(tag = "href", start = offset, end = offset)
.firstOrNull()
?.let {
uriHandler.openUri(it.item)
}
},
style = MaterialTheme.typography.bodyMedium.copy(color = textColor, fontStyle = FontStyle.Italic),
)
}
}
}
if (showBottomSheet) {
LongClickBottomSheet(
onDismiss = {
scope.launch {
sheetState.hide()
showBottomSheet = false
}
},
sheetState = sheetState,
artistUri = artistUri,
songUri = songUri,
)
}
}
}
@Composable
fun Results(
results: List<SearchResult>,
navigateToLyrics: () -> Unit,
setLyricsTitle: (id: String, title: String, subtitle: String, url: Uri) -> Unit,
showBottomSheet: (artistUrl: Uri, songUrl: Uri) -> Unit,
) {
if (results.isEmpty()) {
Text(stringResource(id = R.string.no_results), style = MaterialTheme.typography.titleLarge)
} else {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
stringResource(id = R.string.found_results, results.size),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic,
)
LazyColumn {
items(results) {
ResultListItem(
it,
onClick = {
setLyricsTitle(it.id, it.title, it.artist, it.url)
navigateToLyrics()
},
onLongClick = {
showBottomSheet(it.artistUrl, it.url)
}
)
if (it != results.last())
HorizontalDivider()
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongClickBottomSheet(
onDismiss: () -> Unit,
sheetState: SheetState,
artistUri: Uri,
songUri: Uri,
) {
val uriHandler = LocalUriHandler.current
ModalBottomSheet(
dragHandle = {},
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column {
if (artistUri != Uri.EMPTY) {
Row(
Modifier
.fillMaxWidth()
.clickable { uriHandler.openUri(artistUri.toString()) }
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.LibraryMusic, contentDescription = "Music Library")
Text(stringResource(id = R.string.open_artist_page), fontWeight = FontWeight.Bold)
}
}
if (songUri != Uri.EMPTY) {
Row(
Modifier
.fillMaxWidth()
.clickable { uriHandler.openUri(songUri.toString()) }
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.MusicNote, contentDescription = "Music Note")
Text(stringResource(id = R.string.open_song_page), fontWeight = FontWeight.Bold)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ResultListItem(
item: SearchResult,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
val haptic = LocalHapticFeedback.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
)
) {
val color = LocalContentColor.current
val density = LocalDensity.current
AsyncImage(
item.thumbnail,
error = forwardingPainter(
rememberVectorPainter(image = Icons.Filled.BrokenImage),
colorFilter = ColorFilter.tint(color),
size = with(density) { DpSize(40.dp, 40.dp).toSize() },
),
fallback = rememberVectorPainter(Icons.Filled.Image),
contentDescription = "Thumbnail for ${item.title}",
modifier = Modifier.size(75.dp),
contentScale = ContentScale.Crop,
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
item.title,
fontSize = TextUnit(18F, TextUnitType.Sp),
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
item.artist,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
@Preview(showBackground = true)
fun ResultListItemPreview() {
ResultListItem(
item = SearchResult(
"Test",
"Artist",
id = "1",
url = Uri.EMPTY,
artistUrl = Uri.EMPTY,
thumbnail = Uri.EMPTY
),
onClick = {},
onLongClick = {},
)
}
@@ -0,0 +1,201 @@
package com.illusionman1212.lyricsgrabbr.ui.screens
import android.app.Activity
import android.view.WindowManager.LayoutParams
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.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.automirrored.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.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
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.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
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.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LyricsPage(
goBack: () -> Unit,
lyricsViewModel: LyricsViewModel = viewModel(
factory = LyricsViewModel.Factory
),
settingsViewModel: SettingsViewModel,
) {
val uiState = lyricsViewModel.uiState.collectAsStateWithLifecycle().value
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val context = LocalContext.current
LaunchedEffect(uiState.song, uiState.artist, uiState.url) {
lyricsViewModel.fetchLyricsFromGenius()
}
DisposableEffect(Unit) {
val window = (context as Activity).window
if (settingsState.keepScreenOn) {
window.addFlags(LayoutParams.FLAG_KEEP_SCREEN_ON)
}
onDispose {
window.clearFlags(LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
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.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(
id = R.string.go_back
)
)
}
},
)
},
) { padding ->
Column(Modifier.padding(padding)) {
if (settingsState.keepScreenOn) {
Row(
Modifier
.background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Text(stringResource(id = R.string.screen_will_stay_on).uppercase(), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSecondaryContainer)
}
}
Column(
Modifier
.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.error != null) {
Text(
uiState.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 32.dp)
)
} else if (uiState.lyrics.isEmpty()) {
Instrumental()
} else {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Column(
Modifier.padding(
top = 12.dp,
start = 12.dp,
end = 12.dp,
bottom = 32.dp
)
) {
SelectionContainer {
TextField(
value = uiState.lyrics,
onValueChange = {},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
readOnly = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
}
}
}
}
}
}
}
}
}
@Composable
@Preview(showBackground = true)
fun Instrumental() {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Filled.MusicNote,
contentDescription = "Music Note",
modifier = Modifier.size(50.dp)
)
Text(
stringResource(id = R.string.instrumental),
fontStyle = FontStyle.Italic
)
}
}
@@ -0,0 +1,435 @@
package com.illusionman1212.lyricsgrabbr.ui.screens
import android.util.Patterns
import androidx.appcompat.app.AppCompatDelegate
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.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ScreenLockPortrait
import androidx.compose.material.icons.outlined.Cloud
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.illusionman1212.lyricsgrabbr.R
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.getLangPreferenceDropdownEntries
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsState
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
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
)
@Composable
fun SettingsPage(
goBack: () -> Unit,
navigateToWhitelist: () -> Unit,
viewModel: SettingsViewModel,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.statusBarsPadding()
.padding(vertical = 8.dp, horizontal = 4.dp)
.fillMaxWidth(),
) {
LGIconButton(tooltip = stringResource(id = R.string.go_back), onClick = goBack) {
Icon(
imageVector = Icons.AutoMirrored.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)
geniURL(viewModel, uiState)
Listening(navigateToWhitelist)
PowerManagement(viewModel, uiState)
}
}
}
@Composable
private fun Appearance(viewModel: SettingsViewModel, uiState: SettingsState) {
var themeOpenDialog by remember { mutableStateOf(false) }
var languageOpenDialog by remember { mutableStateOf(false) }
SettingGroup(title = stringResource(id = R.string.appearance)) {
DialogSetting(
title = stringResource(id = R.string.theme),
value = Themes[uiState.appTheme]?.let { stringResource(it) } ?: "None",
icon = Icons.Outlined.Palette,
onClick = { themeOpenDialog = true }
)
DialogSetting(
title = stringResource(id = R.string.language),
value = AppCompatDelegate.getApplicationLocales()[0]?.let {
AppCompatDelegate.getApplicationLocales()[0]?.getDisplayLanguage(
it
)
}
?: stringResource(id = R.string._default),
icon = Icons.Outlined.Translate,
onClick = { languageOpenDialog = true }
)
}
if (themeOpenDialog) {
ThemeDialog(
uiState = uiState,
onDismiss = { themeOpenDialog = false },
onThemeChange = {
viewModel.setTheme(it)
}
)
}
if (languageOpenDialog) {
LanguageDialog(
onDismiss = { languageOpenDialog = false },
)
}
}
@Composable
private fun Listening(navigateToWhitelist: () -> Unit) {
SettingGroup(title = stringResource(id = R.string.listening)) {
Box(
Modifier
.fillMaxWidth()
.clickable { navigateToWhitelist() }
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp)
) {
Icon(imageVector = Icons.Default.Apps, contentDescription = null, modifier = Modifier.width(56.dp))
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = stringResource(id = R.string.whitelist_apps), color = MaterialTheme.colorScheme.onBackground)
Text(
text = stringResource(id = R.string.whitelist_desc),
color = ColorSecondaryLight,
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(end = 24.dp)
)
}
}
}
}
}
@Composable
private fun PowerManagement(viewModel: SettingsViewModel, uiState: SettingsState) {
SettingGroup(title = stringResource(id = R.string.power_management)) {
ToggleSetting(
title = stringResource(id = R.string.keep_device_awake_title),
description = stringResource(id = R.string.keep_device_awake_desc),
icon = Icons.Default.ScreenLockPortrait,
checked = uiState.keepScreenOn
) {
viewModel.toggleKeepScreenOn()
}
}
}
@Composable
private fun geniURL(viewModel: SettingsViewModel, uiState: SettingsState) {
var dialogOpen by remember { mutableStateOf(false) }
SettingGroup(title = "geniURL") {
DialogSetting(
title = stringResource(id = R.string.api_base_url),
value = uiState.geniURLbaseURL,
icon = Icons.Outlined.Cloud,
onClick = { dialogOpen = true }
)
}
if (dialogOpen) {
BaseURLDialog(
uiState = uiState,
viewModel = viewModel,
onDismiss = { dialogOpen = false },
)
}
}
@Composable
private fun BaseURLDialog(uiState: SettingsState, viewModel: SettingsViewModel, onDismiss: () -> Unit) {
var value by remember { mutableStateOf(uiState.geniURLbaseURL) }
var isValidUrl by remember { mutableStateOf(true) }
LGAlertDialog(
onDismiss = onDismiss,
title = stringResource(id = R.string.base_url),
buttons = {
TextButton(
onClick = {
viewModel.setGeniURLBaseURL(value)
onDismiss()
},
enabled = isValidUrl
) {
Text(stringResource(id = R.string.confirm))
}
TextButton(onClick = onDismiss) {
Text(stringResource(id = R.string.cancel))
}
},
modifier = Modifier.fillMaxWidth()
) {
TextField(
value = value,
isError = !isValidUrl,
onValueChange = { newValue ->
isValidUrl = Patterns.WEB_URL.matcher(newValue.lowercase()).matches() && newValue.startsWith("http", true)
value = newValue
},
)
}
}
@Composable
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 LanguageDialog(
onDismiss: () -> Unit,
) {
val langs = LocalContext.current.getLangPreferenceDropdownEntries()
LGAlertDialog(
onDismiss = onDismiss,
title = stringResource(id = R.string.language),
buttons = {
TextButton(onClick = onDismiss) {
Text(stringResource(id = R.string.cancel))
}
},
modifier = Modifier.fillMaxWidth()
) {
LazyColumn {
item {
LGRadioButton(
text = stringResource(id = R.string._default),
onClick = {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList())
},
selected = AppCompatDelegate.getApplicationLocales().isEmpty,
)
}
for ((key, value) in langs) {
item {
LGRadioButton(
text = key,
onClick = {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(value))
},
selected = AppCompatDelegate.getApplicationLocales()[0]?.language == value,
)
}
}
}
}
}
@Composable
fun SettingGroup(title: String, content: @Composable () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Header(title)
Spacer(modifier = Modifier.size(2.dp))
content()
}
}
@Composable
private 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,
description: String? = null,
icon: ImageVector? = 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(imageVector = icon, contentDescription = null, Modifier.width(56.dp))
} else {
Box(modifier = Modifier.width(56.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.onBackground)
if (description != null) {
Text(
text = description,
color = ColorSecondaryLight,
fontSize = 14.sp,
)
}
}
Switch(
checked = checked,
onCheckedChange = null,
thumbContent = if (checked) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
} else null
)
}
}
}
@@ -0,0 +1,162 @@
package com.illusionman1212.lyricsgrabbr.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.illusionman1212.lyricsgrabbr.R
import com.illusionman1212.lyricsgrabbr.ui.components.LGIconButton
import com.illusionman1212.lyricsgrabbr.ui.theme.Typography
import com.illusionman1212.lyricsgrabbr.viewmodels.AppInfo
import com.illusionman1212.lyricsgrabbr.viewmodels.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WhitelistPage(goBack: () -> Unit, viewModel: SettingsViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(topBar = {
TopAppBar(
title = {
TextField(
value = uiState.whitelistSearchQuery,
onValueChange = {
viewModel.filterPackages(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(text = stringResource(id = R.string.search), style = Typography.titleLarge)
},
trailingIcon = {
LGIconButton(tooltip = stringResource(id = R.string.clear), onClick = { viewModel.clearWhitelistSearch() }) {
Icon(Icons.Default.Clear, contentDescription = stringResource(id = R.string.clear))
}
},
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
},
navigationIcon = {
LGIconButton(tooltip = stringResource(id = R.string.go_back), onClick = goBack) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(
id = R.string.go_back
))
}
}
)
}) { padding ->
val context = LocalContext.current
val refreshState = rememberPullToRefreshState()
var isRefreshing by remember { mutableStateOf(false) }
if (uiState.applications.isNotEmpty()) {
isRefreshing = false
}
PullToRefreshBox(
state = refreshState,
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.refreshPackages(context)
},
modifier = Modifier.padding(padding)
) {
LazyColumn(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
if (uiState.applications.any { it.value.isWhitelisted }) {
for ((packageName, appInfo) in uiState.applications.filter { it.value.isWhitelisted }) {
item {
Application(packageName, appInfo, viewModel)
}
}
item {
HorizontalDivider(
Modifier
.width(84.dp)
.padding(vertical = 12.dp))
}
}
for ((packageName, appInfo) in uiState.applications.filter { !it.value.isWhitelisted }) {
item {
Application(packageName, appInfo, viewModel)
}
}
}
}
}
}
@Composable
fun Application(packageName: String, app: AppInfo, viewModel: SettingsViewModel) {
Box(
Modifier
.fillMaxWidth()
.clickable(
role = Role.Checkbox,
onClick = {
if (app.isWhitelisted) {
viewModel.removeFromWhitelist(packageName)
} else {
viewModel.addToWhitelist(packageName)
}
}
)) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(modifier = Modifier.size(32.dp), model = app.icon, contentDescription = null)
Text(app.displayName, Modifier.weight(1f))
Checkbox(
checked = app.isWhitelisted,
onCheckedChange = null,
)
}
}
}
@@ -0,0 +1,93 @@
package com.illusionman1212.lyricsgrabbr.ui.theme
import androidx.compose.ui.graphics.Color
//val Purple80 = Color(0xFFD0BCFF)
//val PurpleGrey80 = Color(0xFFCCC2DC)
//val Pink80 = Color(0xFFEFB8C8)
//
//val Purple40 = Color(0xFF6650a4)
//val PurpleGrey40 = Color(0xFF625b71)
//val Pink40 = Color(0xFF7D5260)
//
//val ColorSecondaryLight = Color(0xFF999999)
val ColorPrimaryLight = Color(0xFFFFFFFF)
val ColorPrimaryVariantLight = Color(0xFFEFEFEF)
val ColorOnPrimaryLight = Color(0xFF000000)
val ColorSecondaryLight = Color(0xFF999999)
val ColorSecondaryVariantLight = Color(0xFF888888)
val ColorOnSecondaryLight = Color(0xFF111111)
val ColorAmoledLight = Color(0xFFFFFFFF)
val ColorPrimaryDark = Color(0xFF222222)
val ColorPrimaryVariantDark = Color(0xFF202020)
val ColorOnPrimaryDark = Color(0xFFFFFFFF)
val ColorSecondaryDark = Color(0xFF696969)
val ColorSecondaryVariantDark = Color(0xFF515151)
val ColorOnSecondaryDark = Color(0xDFFFFFFF)
val ColorAmoledDark = Color(0xFF000000)
val md_theme_light_primary = Color(0xFF7C44A8)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFF2DAFF)
val md_theme_light_onPrimaryContainer = Color(0xFF2E004D)
val md_theme_light_secondary = Color(0xFF675A6E)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFEEDDF5)
val md_theme_light_onSecondaryContainer = Color(0xFF221829)
val md_theme_light_tertiary = Color(0xFF99405F)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFFFD9E1)
val md_theme_light_onTertiaryContainer = Color(0xFF3F001C)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFFFBFF)
val md_theme_light_onBackground = Color(0xFF1D1B1E)
val md_theme_light_surface = Color(0xFFFFFBFF)
val md_theme_light_onSurface = Color(0xFF1D1B1E)
val md_theme_light_surfaceVariant = Color(0xFFEADFEA)
val md_theme_light_onSurfaceVariant = Color(0xFF4B454D)
val md_theme_light_outline = Color(0xFF7C757E)
val md_theme_light_inverseOnSurface = Color(0xFFF6EFF3)
val md_theme_light_inverseSurface = Color(0xFF322F33)
val md_theme_light_inversePrimary = Color(0xFFE1B6FF)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF7C44A8)
val md_theme_light_outlineVariant = Color(0xFFCDC3CE)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFFE1B6FF)
val md_theme_dark_onPrimary = Color(0xFF4B0A76)
val md_theme_dark_primaryContainer = Color(0xFF632A8E)
val md_theme_dark_onPrimaryContainer = Color(0xFFF2DAFF)
val md_theme_dark_secondary = Color(0xFFD2C1D9)
val md_theme_dark_onSecondary = Color(0xFF372C3F)
val md_theme_dark_secondaryContainer = Color(0xFF4E4256)
val md_theme_dark_onSecondaryContainer = Color(0xFFEEDDF5)
val md_theme_dark_tertiary = Color(0xFFFFB1C6)
val md_theme_dark_onTertiary = Color(0xFF5E1131)
val md_theme_dark_tertiaryContainer = Color(0xFF7B2947)
val md_theme_dark_onTertiaryContainer = Color(0xFFFFD9E1)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1D1B1E)
val md_theme_dark_onBackground = Color(0xFFE7E1E5)
val md_theme_dark_surface = Color(0xFF1D1B1E)
val md_theme_dark_onSurface = Color(0xFFE7E1E5)
val md_theme_dark_surfaceVariant = Color(0xFF4B454D)
val md_theme_dark_onSurfaceVariant = Color(0xFFCDC3CE)
val md_theme_dark_outline = Color(0xFF968E98)
val md_theme_dark_inverseOnSurface = Color(0xFF1D1B1E)
val md_theme_dark_inverseSurface = Color(0xFFE7E1E5)
val md_theme_dark_inversePrimary = Color(0xFF7C44A8)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFFE1B6FF)
val md_theme_dark_outlineVariant = Color(0xFF4B454D)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF662D91)
@@ -0,0 +1,141 @@
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
}
}
// BUG: color is not being set on older android version (Marshmallow and others).
// The navigation bar color causes the nav buttons to be very hard to see
// in light mode because the buttons are white.
DisposableEffect(darkTheme) {
(context as ComponentActivity).enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT
),
navigationBarStyle = SystemBarStyle.auto(
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT
)
)
onDispose {}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,34 @@
package com.illusionman1212.lyricsgrabbr.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,58 @@
package com.illusionman1212.lyricsgrabbr.utils
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Response
import okhttp3.Callback
import okio.IOException
import kotlin.coroutines.resumeWithException
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
return suspendCancellableCoroutine { continuation ->
val callback =
object : Callback {
override fun onResponse(call: Call, response: Response) {
continuation.resume(response) {
response.body?.close()
}
}
override fun onFailure(call: Call, e: IOException) {
// Don't bother with resuming the continuation if it is already cancelled.
if (continuation.isCancelled) return
val exception = IOException(e.message, e).apply { stackTrace = callStack }
continuation.resumeWithException(exception)
}
}
enqueue(callback)
continuation.invokeOnCancellation {
try {
cancel()
} catch (ex: Throwable) {
// Ignore cancel exception
}
}
}
}
suspend fun Call.await(): Response {
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
return await(callStack)
}
suspend fun Call.awaitSuccess(): Response {
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
val response = await(callStack)
if (!response.isSuccessful) {
response.close()
throw HttpException(response.code).apply { stackTrace = callStack }
}
return response
}
class HttpException(code: Int) : IllegalStateException("HTTP error $code")
@@ -0,0 +1,74 @@
package com.illusionman1212.lyricsgrabbr.utils
import android.content.Context
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
import androidx.core.os.LocaleListCompat
import com.illusionman1212.lyricsgrabbr.R
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.IOException
// Native stylization is coming soon
// https://issuetracker.google.com/issues/139320238
@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()
}
fun Context.getLocaleListFromXml(): LocaleListCompat {
val tagsList = mutableListOf<CharSequence>()
try {
val xpp: XmlPullParser = resources.getXml(R.xml.locales_config)
while (xpp.eventType != XmlPullParser.END_DOCUMENT) {
if (xpp.eventType == XmlPullParser.START_TAG) {
if (xpp.name == "locale") {
tagsList.add(xpp.getAttributeValue(0))
}
}
xpp.next()
}
} catch (e: XmlPullParserException) {
e.printStackTrace()
} catch (e: IOException) {
e.printStackTrace()
}
return LocaleListCompat.forLanguageTags(tagsList.joinToString(","))
}
fun Context.getLangPreferenceDropdownEntries(): Map<String, String> {
val localeList = getLocaleListFromXml()
val map = mutableMapOf<String, String>()
for (a in 0 until localeList.size()) {
localeList[a].let {
it?.let { it1 -> map.put(it1.getDisplayName(it), it.toLanguageTag()) }
}
}
return map
}
@@ -0,0 +1,146 @@
package com.illusionman1212.lyricsgrabbr.viewmodels
import android.content.Context
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.NotificationEvent
import com.illusionman1212.lyricsgrabbr.data.HomeRepository
import com.illusionman1212.lyricsgrabbr.data.SettingsPreferencesRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.*
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
object UriSerializer : KSerializer<Uri> {
override val descriptor: SerialDescriptor = String.serializer().descriptor
override fun deserialize(decoder: Decoder): Uri {
return Uri.parse(decoder.decodeString())
}
override fun serialize(encoder: Encoder, value: Uri) {
encoder.encodeString(value.toString())
}
}
data class SearchResult(
val title: String,
val artist: String,
val url: Uri,
val artistUrl: Uri,
val thumbnail: Uri,
val id: String,
)
@Serializable
data class Resources(
@Serializable(with = UriSerializer::class)
val thumbnail: Uri,
)
@Serializable
data class Artist(
val name: String,
@Serializable(with = UriSerializer::class)
val url: Uri,
)
@Serializable
data class Meta(
val title: String,
val primaryArtist: Artist,
)
@Serializable
data class IntermediateResult(
@Serializable(with = UriSerializer::class)
val url: Uri,
val meta: Meta,
val resources: Resources,
val id: Int,
)
@Serializable
data class SearchResultsResponse(
val all: List<IntermediateResult>
)
data class HomeState(
val isLoading: Boolean = false,
val results: List<SearchResult> = emptyList(),
val notification: NotificationEvent? = null,
val error: String? = null,
)
class HomeViewModel(private val homeRepository: HomeRepository, private val settingsRepository: SettingsPreferencesRepository): 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.update { state ->
state.copy(isLoading = true, notification = song, error = null)
}
}
fun makeRequestToGenius(context: Context, song: String, artist: String) {
viewModelScope.launch {
_uiState.update { HomeState(true, uiState.value.results, uiState.value.notification, null) }
withContext(Dispatchers.IO) {
val baseUrl = settingsRepository.preferences.first().geniURLbaseURL
val (res, isSuccess) = homeRepository.makeSearchRequest(context, baseUrl, 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.homeRepository, application.settingsPreferencesRepository)
}
}
}
}
@@ -0,0 +1,66 @@
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 = "",
// 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) {
_uiState.update {
it.copy(song = title, artist = subtitle, url = url, trackId = trackId)
}
}
fun fetchLyricsFromGenius() {
_uiState.update { state ->
state.copy(isLoading = true, lyrics = "")
}
viewModelScope.launch {
withContext(Dispatchers.IO) {
val lyrics = lyricsRepository.fetchLyricsFromGenius(uiState.value.url)
_uiState.update { state ->
state.copy(isLoading = false, lyrics = lyrics)
}
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as LGApp)
LyricsViewModel(application.lyricsRepository)
}
}
}
}
@@ -0,0 +1,193 @@
package com.illusionman1212.lyricsgrabbr.viewmodels
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
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.flow.update
import kotlinx.coroutines.launch
data class AppInfo(
val displayName: String,
val icon: Drawable,
var isWhitelisted: Boolean,
)
data class SettingsState(
val appTheme: Int = Theme.SYSTEM.ordinal,
val applications: Map<String, AppInfo> = emptyMap(),
val whitelistSearchQuery: String = "",
val keepScreenOn: Boolean = false,
val geniURLbaseURL: String = "",
val whitelistPromptSeen: Boolean = true,
)
class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesRepository, context: Context): ViewModel() {
private var immutablePackages: Map<String, AppInfo> = emptyMap()
private val _uiState = MutableStateFlow(SettingsState())
val uiState = _uiState.asStateFlow()
suspend fun getAppTheme(): Int {
return settingsPrefsRepo.preferences.first().appTheme
}
fun setTheme(theme: Theme) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setTheme(theme)
}
}
fun discardWhitelistPrompt() {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.discardWhitelistPrompt()
}
}
fun setGeniURLBaseURL(baseUrl: String) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setGeniURLBaseURL(baseUrl)
_uiState.update { state ->
state.copy(geniURLbaseURL = baseUrl)
}
}
}
fun toggleKeepScreenOn() {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.toggleKeepScreenOn()
}
}
private suspend fun getWhitelistedApps(): Set<String> {
return settingsPrefsRepo.preferences.first().whitelist
}
private fun getPackages(context: Context) {
if (immutablePackages.isNotEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
val pm = context.packageManager
val packages = pm.getInstalledApplications(PackageManager.GET_META_DATA)
.filter { it.packageName != context.packageName && pm.getLaunchIntentForPackage(it.packageName) != null }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.loadLabel(pm).toString() })
val apps: MutableMap<String, AppInfo> = mutableMapOf()
val whitelistedApps = getWhitelistedApps()
packages.forEach { pkg ->
val label = pkg.loadLabel(pm)
val icon = pkg.loadIcon(pm)
apps[pkg.packageName] = AppInfo(
displayName = label.toString(),
icon = icon,
isWhitelisted = pkg.packageName in whitelistedApps
)
}
immutablePackages = apps
_uiState.update { state ->
state.copy(
applications = apps
)
}
}
}
fun refreshPackages(context: Context) {
_uiState.update {
val apps = uiState.value.applications.toMutableMap()
apps.clear()
it.copy(whitelistSearchQuery = "", applications = apps)
}
val apps = immutablePackages.toMutableMap()
apps.clear()
immutablePackages = apps
getPackages(context)
}
fun filterPackages(query: String) {
_uiState.update { state ->
state.copy(
applications = immutablePackages.filter {
it.value.displayName.lowercase().contains(query.lowercase())
}.toMutableMap(),
whitelistSearchQuery = query,
)
}
}
fun clearWhitelistSearch() {
_uiState.update {
it.copy(applications = immutablePackages, whitelistSearchQuery = "")
}
}
fun addToWhitelist(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { state ->
val apps = immutablePackages.toMutableMap()
apps[packageName] = apps[packageName]!!.copy(isWhitelisted = true)
immutablePackages = apps
state.copy(
applications = immutablePackages.filter {
it.value.displayName.lowercase().contains(uiState.value.whitelistSearchQuery.lowercase())
}.toMutableMap(),
)
}
settingsPrefsRepo.addToWhitelist(packageName)
}
}
fun removeFromWhitelist(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { state ->
val apps = immutablePackages.toMutableMap()
apps[packageName] = apps[packageName]!!.copy(isWhitelisted = false)
immutablePackages = apps
state.copy(
applications = immutablePackages.filter {
it.value.displayName.lowercase().contains(uiState.value.whitelistSearchQuery.lowercase())
}.toMutableMap(),
)
}
settingsPrefsRepo.removeFromWhitelist(packageName)
}
}
init {
getPackages(context)
viewModelScope.launch {
settingsPrefsRepo.preferences.collectLatest {
_uiState.update { state ->
state.copy(
appTheme = it.appTheme,
keepScreenOn = it.keepScreenOn,
whitelistPromptSeen = it.whitelistPromptSeen,
geniURLbaseURL = it.geniURLbaseURL,
)
}
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as LGApp)
SettingsViewModel(application.settingsPreferencesRepository, application.applicationContext)
}
}
}
}

Before

Width:  |  Height:  |  Size: 499 B

After

Width:  |  Height:  |  Size: 499 B

Before

Width:  |  Height:  |  Size: 343 B

After

Width:  |  Height:  |  Size: 343 B

Before

Width:  |  Height:  |  Size: 656 B

After

Width:  |  Height:  |  Size: 656 B

Before

Width:  |  Height:  |  Size: 941 B

After

Width:  |  Height:  |  Size: 941 B

@@ -0,0 +1,50 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.75"
android:scaleY="0.75">
<path
android:pathData="M0,144L144,144L144,0L0,0Z"
android:strokeAlpha="0"
android:fillColor="#662d91"
android:strokeColor="#00000000"
android:fillType="nonZero"
android:fillAlpha="0"/>
<group>
<clip-path
android:pathData="M17.809,17.809L126.191,17.809L126.191,126.191L17.809,126.191Z"/>
<path
android:pathData="m72.594,88.91c0.546,5.172 -4.992,9.998 -12.37,10.777 -7.378,0.78 -13.803,-2.783 -14.349,-7.955 -0.546,-5.173 4.993,-9.998 12.371,-10.778C65.625,80.175 72.048,83.738 72.594,88.91"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="M72.641,88.905 L69.346,89.625C68.776,89.749 68.265,88.894 68.205,87.715l-1.898,-37.161c-0.072,-1.403 0.52,-2.565 1.193,-2.346L69.827,48.964c0.464,0.152 0.822,0.928 0.871,1.895z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m91.303,58.314 l-22.224,-3.077c-1.76,-0.244 -2.991,-1.87 -2.747,-3.631l0.014,-1.268c0.416,-0.968 0.068,-2.973 1.325,-2.798l24.674,3.238c1.257,0.175 2.136,1.335 1.961,2.592l-0.412,2.982c-0.174,1.257 -1.334,2.136 -2.591,1.962"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m95.572,65.33 l-13.619,4.34c-0.206,0.066 -0.431,-0.107 -0.457,-0.351l-0.009,-0.085c-0.082,-0.774 -0.381,-1.521 -0.854,-2.138 -0.129,-0.17 -0.089,-0.414 0.081,-0.495L93.852,60.464C94.021,60.386 94.999,61.437 94.999,61.437 95.523,62.111 95.844,62.942 95.907,63.794 95.907,63.794 95.831,65.247 95.572,65.33"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m95.682,80.164 l-13.574,-1.079c-0.134,-0.011 -0.224,-0.173 -0.182,-0.329l0.074,-0.279c0.195,-0.723 0.198,-1.5 0.012,-2.223l-0.052,-0.199c-0.032,-0.125 0.042,-0.255 0.15,-0.261L95.903,74.998C96.03,74.99 96.488,76.374 96.488,76.374 96.7,77.168 96.686,78.028 96.449,78.816 96.449,78.816 95.891,80.181 95.682,80.164"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m93.176,93.612 l-12.263,-5.156c-0.121,-0.05 -0.154,-0.232 -0.065,-0.367l0.156,-0.242c0.405,-0.627 0.653,-1.365 0.704,-2.108l0.015,-0.205c0.009,-0.128 0.117,-0.229 0.22,-0.203L94.994,88.771C95.115,88.803 95.108,90.257 95.108,90.257 95.056,91.076 94.774,91.888 94.308,92.565 94.308,92.565 93.364,93.691 93.176,93.612"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
</group>
</group>
</vector>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Lyrics Grabbr</string>
<!-- General -->
<string name="go_back">رجوع</string>
<string name="cancel">إلغاء</string>
<string name="confirm">تأكيد</string>
<string name="timeout">لقد انتهت مهلة الطلب، حاول مرة أخرى لاحقًا</string>
<string name="generic_error">لقد حدث خطأ، حاول مرة أخرى لاحقاً</string>
<string name="internal_server_error">تأكد من تشغيل خادم geniURL الخاص بك إذا كنت تستضيف ذاتيًا أو اتصل بمضيفك.</string>
<string name="invalid_json">الاستجابة لم تكن JSON صالحة</string>
<string name="failed_to_map_json">فشل تعيين JSON المفكك إلى النوع الداخلي</string>
<string name="retry">أعد المحاولة</string>
<!-- Home -->
<string name="no_supported_player">لا يوجد مشغل مدعوم قيد التشغيل</string>
<string name="open_settings">فتح الإعدادات</string>
<string name="notification_listen_permission">إذن قراءة الإشعارات</string>
<string name="notification_listen_desc">هذا التطبيق يحتاج إذن قراءة الإشعارات للعمل بشكل صحيح. يرجى منح الإذن في شاشة الإعدادات التالية.\nإحذر أن الطريقة الوحيدة لإيقاف الخدمة هي إبطال إذن قراءة الإشعارات.</string>
<string name="no_results">لم يتم العثور على نتائج</string>
<string name="found_results">تم العثور على %1$d نتيجة</string>
<string name="powered_by">مشغل بواسطة<annotation href="https://github.com/sv443/geniURL">geniURL</annotation></string>
<string name="open_song_page">افتح صفحة الأغنية</string>
<string name="open_artist_page">افتح صفحة الفنان</string>
<string name="notification_post_permission">مطلوب إذن الإشعارات</string>
<string name="notification_post_permission_desc">يتطلب هذا التطبيق أذونات الإشعارات لبدء خدمة مقدمة بغرض الاستماع إلى إشعارات الموسيقى، ولا يمكن أن يعمل بدونها.</string>
<string name="notification_post_permission_not_granted">لم يتم منح إذن الإشعارات، لن يعمل التطبيق بشكل صحيح</string>
<string name="grant_permission">منح الإذن</string>
<string name="understood">مفهوم</string>
<string name="whitelist_prompt_desc">يعمل التطبيق على أساس القائمة البيضاء. وهذا يمنحك التحكم في التطبيقات التي سنستمع إليها ويقلل أيضًا من التشويش على التطبيق.\nيمكنك التحكم في القائمة البيضاء من الإعدادات</string>
<!-- Settings -->
<string name="settings">الإعدادات</string>
<string name="appearance">المظهر</string>
<string name="theme">مظهر التطبيق</string>
<string name="theme_light">نهار</string>
<string name="theme_dark">ليلي</string>
<string name="theme_system">استخدم مظهر النظام</string>
<string name="language">اللغة</string>
<string name="_default">استخدم لغة النظام</string>
<string name="listening">الإستماع</string>
<string name="whitelist_apps">تطبيقات القائمة البيضاء</string>
<string name="whitelist_desc">اختر التطبيقات التي سيستمع التطبيق إلى إشعاراتها</string>
<string name="search">بحث</string>
<string name="clear">مسح</string>
<string name="power_management">إدارة إستخدام الطافة</string>
<string name="keep_device_awake_title">أبق الجهاز مستيقظا</string>
<string name="keep_device_awake_desc">أبق الجهاز مستيقظا على صفحة كلمات الأغاني (يزيد من استخدام البطارية)</string>
<string name="screen_will_stay_on">ستبقى الشاشة على قيد التشغيل</string>
<string name="api_base_url">عنوان URL الأساسي لواجهة برمجة التطبيقات (API)</string>
<string name="base_url">عنوان URL الأساسي</string>
<!-- Lyrics -->
<string name="instrumental">هذه الأغنية موسيقية</string>
<!-- Notification -->
<string name="service_running">الخدمة قيد التشغيل</string>
<string name="stop_service">وقف الخدمة</string>
<string name="service_notif_desc_init">قم بتشغيل بعض الأغاني على مشغل مدعوم للحصول على كلمات الأغاني</string>
<string name="service_notif_desc_playing">انقر للحصول على كلمات الأغاني</string>
<string name="now_playing">يتم الآن تشغيل %1$s لـ %2$s</string>
</resources>
+54
View File
@@ -0,0 +1,54 @@
<resources>
<string name="app_name" translatable="false">Lyrics Grabbr</string>
<!-- General -->
<string name="go_back">Zurück</string>
<string name="cancel">Abbrechen</string>
<string name="timeout">Anfrage ist abgelaufen, bitte versuche es später erneut</string>
<string name="generic_error">Ein Fehler ist aufgetreten, bitte versuche es später erneut</string>
<string name="retry">Erneut versuchen</string>
<!-- Home -->
<string name="no_supported_player">Keinen unterstützten Player gefunden</string>
<string name="open_settings">Einstellungen öffnen</string>
<string name="notification_listen_permission">Berechtigung zum Auslesen von Benachrichtigungen</string>
<string name="notification_listen_desc">Diese App benötigt die Berechtigung zum Auslesen von Benachrichtigungen, um ordnungsgemäß zu funktionieren. Bitte erteile diese Berechtigung auf dem nächsten Bildschirm.\nBITTE BEACHTEN: der einzige Weg den Dienst zu stoppen ist die Berechtigung zum Auslesen von Benachrichtigungen zu widerrufen.</string>
<string name="no_results">Keine Ergebnisse gefunden</string>
<string name="found_results">%1$d Ergebnis(se) gefunden</string>
<string name="powered_by">Verwendet <annotation href="https://github.com/sv443/geniURL">geniURL</annotation></string>
<string name="open_song_page">Seite des Songs öffnen</string>
<string name="open_artist_page">Seite des Künstlers öffnen</string>
<string name="notification_post_permission">Berechtigung für Benachrichtigungen erforderlich</string>
<string name="notification_post_permission_desc">Lyrics Grabbr benötigt die Berechtigung Benachrichtigungen zu lesen, um einen Vordergrunddienst zum Abhören zu starten und kann nicht ohne diese funktionieren.</string>
<string name="notification_post_permission_not_granted">Berechtigung zum Abhören von Benachrichtigungen nicht erteilt, daher wird die App wird nicht ordnungsgemäß funktionieren.</string>
<string name="grant_permission">Berechtigung erteilen</string>
<!-- Settings -->
<string name="settings">Einstellungen</string>
<string name="appearance">Aussehen</string>
<string name="theme">Farbschema</string>
<string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string>
<string name="theme_system">Systemstandard</string>
<string name="language">Sprache</string>
<string name="_default">Systemsprache</string>
<string name="listening">Erlaubt</string>
<string name="whitelist_apps">Erlaubte Apps</string>
<string name="whitelist_desc">Wähle aus, welchen Apps Lyrics Grabbr zuhören soll</string>
<string name="search">Suche</string>
<string name="clear">Löschen</string>
<string name="power_management">Energieeinstellungen</string>
<string name="keep_device_awake_title">Gerät wachhalten</string>
<string name="keep_device_awake_desc">Gerät auf der Songtext-Seite wachhalten (erhöht den Batterieverbrauch)</string>
<string name="screen_will_stay_on">Bildschirm bleibt an</string>
<!-- Lyrics -->
<string name="instrumental">Dies ist ein instrumentaler Song</string>
<!-- Notification -->
<string name="service_running">Dienst läuft</string>
<string name="stop_service">Dienst anhalten</string>
<string name="service_notif_desc_init">Spiele Songs mit einem unterstützten Player, um Songtexte zu erhalten</string>
<string name="service_notif_desc_playing">Für den Songtext antippen</string>
<string name="now_playing">Es läuft %1$s von %2$s</string>
</resources>
+62
View File
@@ -0,0 +1,62 @@
<resources>
<string name="app_name" translatable="false">Lyrics Grabbr</string>
<!-- General -->
<string name="go_back">Go Back</string>
<string name="cancel">Cancel</string>
<string name="confirm">Confirm</string>
<string name="timeout">Request has timed out, try again later</string>
<string name="generic_error">An error has occurred, try again later</string>
<string name="internal_server_error">Internal server error. Make sure your geniURL server is running if self-hosting or contact your hoster.</string>
<string name="invalid_json">Response was not valid JSON</string>
<string name="failed_to_map_json">Failed to map decoded JSON to internal type.</string>
<string name="retry">Retry</string>
<!-- Home -->
<string name="no_supported_player">No supported player is running</string>
<string name="open_settings">Open Settings</string>
<string name="notification_listen_permission">Notification Listening Permission</string>
<string name="notification_listen_desc">This app requires permission to listen to notifications to function properly. Please grant permission in the next screen.\nBEWARE that the only way to stop the service is to revoke the notification listening permission.</string>
<string name="no_results">No results found</string>
<string name="found_results">Found %1$d result(s)</string>
<string name="powered_by">Powered By <annotation href="https://github.com/sv443/geniURL">geniURL</annotation></string>
<string name="open_song_page">Open Song Page</string>
<string name="open_artist_page">Open Artist Page</string>
<string name="notification_post_permission">Notification Permission Required</string>
<string name="notification_post_permission_desc">LyricsGrabbr requires notification permissions to start a foreground service for the purpose of listening to music notifications, and cannot function without it.</string>
<string name="notification_post_permission_not_granted">Notification permission is not granted, the app will not function properly</string>
<string name="grant_permission">Grant Permission</string>
<string name="understood">Understood</string>
<string name="whitelist_prompt_desc">The application operates on a whitelist basis. This gives you control over which apps we\'ll listen to and also reduces noise for the application.\nYou may control the whitelist from the settings.</string>
<!-- Settings -->
<string name="settings">Settings</string>
<string name="appearance">Appearance</string>
<string name="theme">Theme</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="theme_system">Use System Theme</string>
<string name="language">Language</string>
<string name="_default">Use System Language</string>
<string name="listening">Listening</string>
<string name="whitelist_apps">Whitelist Apps</string>
<string name="whitelist_desc">Choose which apps Lyrics Grabbr will listen to</string>
<string name="search">Search</string>
<string name="clear">Clear</string>
<string name="power_management">Power Management</string>
<string name="keep_device_awake_title">Keep device awake</string>
<string name="keep_device_awake_desc">Keep device awake on lyrics page (Increases battery usage)</string>
<string name="screen_will_stay_on">Screen Will Stay On</string>
<string name="api_base_url">API Base URL</string>
<string name="base_url">Base URL</string>
<!-- Lyrics -->
<string name="instrumental">This song is an instrumental</string>
<!-- Notification -->
<string name="service_running">Service Running</string>
<string name="stop_service">Stop Service</string>
<string name="service_notif_desc_init">Play some songs on a supported player to get lyrics</string>
<string name="service_notif_desc_playing">Tap to get lyrics</string>
<string name="now_playing">Now Playing %1$s By %2$s</string>
</resources>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LyricsGrabbr" parent="Theme.Material3.DayNight.NoActionBar" />
<style name="Theme.LyricsGrabbr.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">#FF662D91</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="windowSplashScreenAnimationDuration">800</item>
<item name="postSplashScreenTheme">@style/Theme.LyricsGrabbr</item>
</style>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ar" />
<locale android:name="de" />
</locale-config>
@@ -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)
}
}
+5
View File
@@ -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
}
@@ -0,0 +1,2 @@
## Veröffentlichung auf F-droid
Initiale Veröffentlichung auf F-droid
@@ -0,0 +1,2 @@
- App mit Kotlin und Jetpack Compose neu geschrieben
- Möglichkeit hinzugefügt, Spotify für Songtexte zu verwenden
@@ -0,0 +1,4 @@
- Eine Whitelist von Apps ist jetzt erforderlich, damit die App Benachrichtigungen von ihnen abrufen kann
- Spotify wurde aufgrund des nicht-FOSS-SDKs vollständig entfernt
- Spracheinstellungen (Hilfe bei Übersetzungen ist sehr willkommen)
- Einstellung hinzugefügt, um das Gerät wach zu halten, während Songtexte angezeigt werden
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

@@ -0,0 +1 @@
Lyrics Grabbr bietet Songtexte für das aktuell abgespielte Lied anhand von Benachrichtigungen an
@@ -0,0 +1 @@
Lyrics Grabbr
@@ -0,0 +1,2 @@
## F-droid release
Initial release on f-droid
@@ -0,0 +1,2 @@
- Rewrote the app in Kotlin with jetpack compose
- Added ability to use Spotify for lyrics
@@ -0,0 +1,4 @@
- A whitelist of apps is now required in order for the app to pick up on notifications from them
- Completely remove Spotify due to non-FOSS SDK
- Language settings (help with translations is much appreciated)
- Added a setting to keep the device awake when reading lyrics
@@ -0,0 +1,4 @@
- Fix app crash when geniURL instance is down.
- Show a one-time notice related to whitelisting apps.
- Added a setting to allow for changing the geniURL instance.
- German translation
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

@@ -0,0 +1 @@
Lyrics grabbr provides lyrics for the currently playing song
@@ -0,0 +1 @@
Lyrics Grabbr
+23
View File
@@ -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
Binary file not shown.
@@ -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

Some files were not shown because too many files have changed in this diff Show More