├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── ci.yml ├── .gitignore ├── .idea ├── .gitignore ├── appInsightsSettings.xml ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── deploymentTargetDropDown.xml ├── deploymentTargetSelector.xml ├── discord.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml ├── runConfigurations.xml └── vcs.xml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ └── pl │ │ └── lambada │ │ └── songsync │ │ ├── MainActivity.kt │ │ ├── activities │ │ └── quicksearch │ │ │ ├── QuickLyricsSearchActivity.kt │ │ │ ├── QuickLyricsSearchPage.kt │ │ │ ├── components │ │ │ ├── ErrorCard.kt │ │ │ ├── ExpandableOutlinedCard.kt │ │ │ ├── QuickLyricsSongInfo.kt │ │ │ ├── SquaredButton.kt │ │ │ └── SyncedLyricsLine.kt │ │ │ └── viewmodel │ │ │ ├── QuickLyricsSearchViewModel.kt │ │ │ └── QuickLyricsSearchViewModelFactory.kt │ │ ├── data │ │ ├── UserSettingsController.kt │ │ └── remote │ │ │ ├── PaxMusicHelper.kt │ │ │ ├── UpdateService.kt │ │ │ ├── github │ │ │ └── GithubAPI.kt │ │ │ └── lyrics_providers │ │ │ ├── LyricsProviderService.kt │ │ │ ├── others │ │ │ ├── AppleAPI.kt │ │ │ ├── LRCLibAPI.kt │ │ │ ├── MusixmatchAPI.kt │ │ │ ├── NeteaseAPI.kt │ │ │ └── QQMusicAPI.kt │ │ │ └── spotify │ │ │ ├── SpotifyAPI.kt │ │ │ └── SpotifyLyricsAPI.kt │ │ ├── domain │ │ └── model │ │ │ ├── Release.kt │ │ │ ├── Song.kt │ │ │ ├── SongInfo.kt │ │ │ ├── Sort.kt │ │ │ └── lyrics_providers │ │ │ ├── PaxMusicFormat.kt │ │ │ ├── others │ │ │ ├── Apple.kt │ │ │ ├── LRCLib.kt │ │ │ ├── Musixmatch.kt │ │ │ ├── Netease.kt │ │ │ └── QQMusic.kt │ │ │ └── spotify │ │ │ ├── AccessToken.kt │ │ │ ├── SpotifyApi.kt │ │ │ ├── SpotifySyncedLyricsApi.kt │ │ │ └── WebPlayerToken.kt │ │ ├── services │ │ └── NotificationListener.kt │ │ ├── ui │ │ ├── Navigator.kt │ │ ├── common │ │ │ ├── AnimatedComposables.kt │ │ │ └── ComposableAnimations.kt │ │ ├── components │ │ │ ├── CommonTexts.kt │ │ │ ├── ProvidersDropdownMenu.kt │ │ │ ├── SettingsHeadLabel.kt │ │ │ ├── SongCard.kt │ │ │ ├── SwitchItem.kt │ │ │ ├── TextField.kt │ │ │ ├── dialogs │ │ │ │ └── NoInternetDialog.kt │ │ │ └── dropdown │ │ │ │ ├── AnimatedDropdownMenu.kt │ │ │ │ ├── DropdownMenuImplementation.kt │ │ │ │ └── M3ElevationTokens.kt │ │ ├── screens │ │ │ ├── home │ │ │ │ ├── HomeScreen.kt │ │ │ │ ├── HomeViewModel.kt │ │ │ │ └── components │ │ │ │ │ ├── BatchDownloadLyrics.kt │ │ │ │ │ ├── FilterAndSongCount.kt │ │ │ │ │ ├── FiltersDialog.kt │ │ │ │ │ ├── HomeAppBar.kt │ │ │ │ │ ├── HomeSearchBar.kt │ │ │ │ │ ├── HomeSearchThing.kt │ │ │ │ │ ├── HomeTopAppBarDropDown.kt │ │ │ │ │ ├── SongItem.kt │ │ │ │ │ ├── SortDialog.kt │ │ │ │ │ └── batchDownload │ │ │ │ │ ├── BatchDownloadWarningDialog.kt │ │ │ │ │ ├── DownloadCompleteDialog.kt │ │ │ │ │ ├── DownloadProgressDialog.kt │ │ │ │ │ ├── LegacyPromptDialog.kt │ │ │ │ │ └── RateLimitedDialog.kt │ │ │ ├── init │ │ │ │ ├── InitScreen.kt │ │ │ │ ├── InitScreenViewModel.kt │ │ │ │ └── components │ │ │ │ │ ├── InitTopBar.kt │ │ │ │ │ ├── PermissionItem.kt │ │ │ │ │ └── permissions │ │ │ │ │ ├── AllFilesAccess.kt │ │ │ │ │ ├── NotificationPermission.kt │ │ │ │ │ └── PostNotifications.kt │ │ │ ├── lyricsFetch │ │ │ │ ├── LyricsFetchScreen.kt │ │ │ │ ├── LyricsFetchViewModel.kt │ │ │ │ └── components │ │ │ │ │ ├── CloudProviderTitle.kt │ │ │ │ │ ├── FailedDialogue.kt │ │ │ │ │ ├── LocalSongContent.kt │ │ │ │ │ ├── LyricsSuccessContent.kt │ │ │ │ │ ├── NoConnectionDialogue.kt │ │ │ │ │ ├── NotSubmittedContent.kt │ │ │ │ │ └── SuccessContent.kt │ │ │ └── settings │ │ │ │ ├── SettingsScreen.kt │ │ │ │ ├── SettingsViewModel.kt │ │ │ │ └── components │ │ │ │ ├── AppInfoSection.kt │ │ │ │ ├── ContributorsSection.kt │ │ │ │ ├── CreditsSection.kt │ │ │ │ ├── ExternalLinkSection.kt │ │ │ │ ├── MarqueeSwitch.kt │ │ │ │ ├── MultiPersonSwitch.kt │ │ │ │ ├── OffsetModeSwitch.kt │ │ │ │ ├── PureBlackThemeSwitch.kt │ │ │ │ ├── RomanizationSwitch.kt │ │ │ │ ├── SdCardPathSetting.kt │ │ │ │ ├── SettingsScreenTopBar.kt │ │ │ │ ├── ShowPathSwitch.kt │ │ │ │ ├── SupportSection.kt │ │ │ │ ├── SyncedLyricsSwitch.kt │ │ │ │ ├── TranslationSection.kt │ │ │ │ ├── TranslationSwitch.kt │ │ │ │ ├── UpdateAvailableDialog.kt │ │ │ │ └── Utils.kt │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ └── util │ │ ├── DataStorePreferences.kt │ │ ├── Exceptions.kt │ │ ├── LyricsUtils.kt │ │ ├── MiscelaneousUtils.kt │ │ ├── ResourceState.kt │ │ ├── ScreenState.kt │ │ ├── ext │ │ ├── ComposeExt.kt │ │ ├── ContextExt.kt │ │ ├── FileExt.kt │ │ ├── IntExt.kt │ │ └── StringExt.kt │ │ ├── networking │ │ └── Ktor.kt │ │ └── ui │ │ ├── AnimationSpecs.kt │ │ ├── MaterialSharedAxis.kt │ │ └── MotionConstants.kt │ └── res │ ├── drawable-v31 │ ├── ic_launcher_foreground.xml │ ├── ic_notification.xml │ └── ic_song.xml │ ├── drawable │ ├── ic_launcher_background.xml │ ├── ic_launcher_foreground.xml │ ├── ic_notification.xml │ └── ic_song.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-anydpi-v31 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ └── ic_launcher.png │ ├── mipmap-mdpi │ └── ic_launcher.png │ ├── mipmap-xhdpi │ └── ic_launcher.png │ ├── mipmap-xxhdpi │ └── ic_launcher.png │ ├── mipmap-xxxhdpi │ └── ic_launcher.png │ ├── resources.properties │ ├── values-ar │ └── strings.xml │ ├── values-cs │ └── strings.xml │ ├── values-de │ └── strings.xml │ ├── values-es │ └── strings.xml │ ├── values-et │ └── strings.xml │ ├── values-fa │ └── strings.xml │ ├── values-fil │ └── strings.xml │ ├── values-fr │ └── strings.xml │ ├── values-in │ └── strings.xml │ ├── values-it │ └── strings.xml │ ├── values-ja │ └── strings.xml │ ├── values-pl │ └── strings.xml │ ├── values-pt-rBR │ └── strings.xml │ ├── values-pt │ └── strings.xml │ ├── values-ro │ └── strings.xml │ ├── values-ru │ └── strings.xml │ ├── values-ta │ └── strings.xml │ ├── values-tr │ └── strings.xml │ ├── values-v31 │ └── themes.xml │ ├── values-vi │ └── strings.xml │ ├── values-zh-rCN │ └── strings.xml │ ├── values-zh-rTW │ └── strings.xml │ ├── values │ ├── ic_launcher_background.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ ├── backup_rules.xml │ ├── data_extraction_rules.xml │ └── file_paths.xml ├── build.gradle.kts ├── fastlane └── metadata │ └── android │ └── en-US │ ├── full_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── screenshot1.jpg │ │ ├── screenshot2.jpg │ │ ├── screenshot3.jpg │ │ ├── screenshot4.jpg │ │ └── screenshot5.jpg │ └── short_description.txt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── screenshots ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png ├── screenshot4.png └── screenshot5.png └── settings.gradle.kts /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Smartphone (please complete the following information):** 27 | 28 | - Device: [e.g. Samsung Galaxy S10, Poco X3 Pro] 29 | - OS: [e.g. MIUI 13 (Android 12), Pixel Experience 13] 30 | - App version [e.g. v1.3] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build APK 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | pull_request: 8 | branches: 9 | - "**" 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build-debug: 14 | name: Build debug APK 15 | runs-on: ubuntu-latest 16 | 17 | permissions: 18 | contents: read 19 | packages: write 20 | checks: write 21 | pull-requests: write 22 | statuses: write 23 | security-events: write 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v2 28 | 29 | - name: Set up JDK 30 | uses: actions/setup-java@v3 31 | with: 32 | distribution: "zulu" 33 | java-version: "17" 34 | 35 | - name: Build debug APK 36 | run: ./gradlew assembleDebug 37 | 38 | - name: Upload debug APK 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: app-debug.apk 42 | path: ./app/build/outputs/apk/debug/app-debug.apk 43 | 44 | build-release: 45 | if: github.event_name == 'push' 46 | name: Build signed release APK 47 | runs-on: ubuntu-latest 48 | 49 | env: 50 | RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} 51 | RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEYSTORE_ALIAS }} 52 | RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} 53 | KEYSTORE_BASE_64: ${{ secrets.KEYSTORE_BASE_64 }} 54 | 55 | permissions: 56 | contents: read 57 | packages: write 58 | checks: write 59 | pull-requests: write 60 | statuses: write 61 | security-events: write 62 | 63 | steps: 64 | - name: Checkout code 65 | uses: actions/checkout@v2 66 | 67 | - name: Set up JDK 68 | uses: actions/setup-java@v3 69 | with: 70 | distribution: "zulu" 71 | java-version: "17" 72 | 73 | - name: Set up signing key 74 | run: | 75 | base64 -d <<< $KEYSTORE_BASE_64 > release.keystore 76 | echo "RELEASE_STORE_FILE=$(realpath release.keystore)" >> $GITHUB_ENV 77 | 78 | - name: Build release APK 79 | run: ./gradlew assembleRelease 80 | 81 | - name: Upload release APK 82 | uses: actions/upload-artifact@v4 83 | with: 84 | name: app-release.apk 85 | path: ./app/build/outputs/apk/release/app-release.apk 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | .kotlin 17 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | 5 | deploymentTargetDropDown.xml 6 | deploymentTargetSelector.xml -------------------------------------------------------------------------------- /.idea/appInsightsSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 25 | 26 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 119 | 120 | 122 | 123 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetDropDown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/discord.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 47 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "githubPullRequests.ignoredPullRequestBranches": ["master"], 3 | "java.configuration.updateBuildConfiguration": "automatic" 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SongSync 2 | 3 | A simple Android app to download lyrics (.lrc files) for songs in your music library. 4 | 5 | ### Features 6 | 7 | - Download lyrics for whole music library with a single click 8 | - Download lyrics for individual songs in your music library 9 | - Embed lyrics directly to song 10 | - Download lyrics from various providers 11 | - Search for lyrics for songs not in your music library (and download them) 12 | 13 | ### Screenshots (v4.0.0) 14 | 15 | ![Screenshot 1](https://github.com/Lambada10/SongSync/raw/master/screenshots/screenshot1.png) 16 | ![Screenshot 2](https://github.com/Lambada10/SongSync/raw/master/screenshots/screenshot2.png) 17 | ![Screenshot 3](https://github.com/Lambada10/SongSync/raw/master/screenshots/screenshot3.png) 18 | ![Screenshot 4](https://github.com/Lambada10/SongSync/raw/master/screenshots/screenshot4.png) 19 | ![Screenshot 5](https://github.com/Lambada10/SongSync/raw/master/screenshots/screenshot5.png) 20 | 21 | ### Installation 22 | 23 | Get it on IzzyOnDroid 24 | 25 | You can download the latest version of the app from the [releases page](https://github.com/Lambada10/SongSync/releases). 26 | 27 | ### Translation 28 | 29 | If you would like to help translating this app, you can do so [here](https://hosted.weblate.org/engage/songsync/). 30 | 31 | ### License 32 | 33 | This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](https://github.com/Lambada10/SongSync/blob/master/LICENSE) file for details. 34 | 35 | ### Thanks to 36 | 37 | - [Spotify](https://developer.spotify.com/documentation/web-api) 38 | - [SpotifyLyricsAPI](https://github.com/akashrchandran/spotify-lyrics-api) 39 | - [syncedlyrics](https://github.com/0x7d4/syncedlyrics) 40 | - [Statusbar Lyric Ext](https://github.com/cjybyjk/StatusBarLyricExt) 41 | - [Alex](https://github.com/paxsenix0) for access to various apis 42 | 43 | ### Friend projects 44 | 45 | [Gramophone](https://github.com/AkaneTan/Gramophone) 46 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release 3 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.androidApplication) 3 | alias(libs.plugins.kotlinAndroid) 4 | alias(libs.plugins.serialization) 5 | alias(libs.plugins.parcelize) 6 | alias(libs.plugins.compose.compiler) 7 | } 8 | 9 | android { 10 | namespace = "pl.lambada.songsync" 11 | compileSdk = 35 12 | 13 | defaultConfig { 14 | applicationId = "pl.lambada.songsync" 15 | minSdk = 21 16 | //noinspection OldTargetApi 17 | targetSdk = 35 18 | versionCode = 433 19 | versionName = "4.3.3" 20 | 21 | vectorDrawables { 22 | useSupportLibrary = true 23 | } 24 | } 25 | androidResources { 26 | generateLocaleConfig = true 27 | } 28 | signingConfigs { 29 | create("release") { 30 | if (System.getenv("RELEASE_STORE_FILE") != null) { 31 | storeFile = file(System.getenv("RELEASE_STORE_FILE")) 32 | storePassword = System.getenv("RELEASE_STORE_PASSWORD") 33 | keyAlias = System.getenv("RELEASE_KEY_ALIAS") 34 | keyPassword = System.getenv("RELEASE_KEY_PASSWORD") 35 | } 36 | } 37 | } 38 | buildTypes { 39 | release { 40 | isMinifyEnabled = true 41 | proguardFiles( 42 | getDefaultProguardFile("proguard-android-optimize.txt"), 43 | "proguard-rules.pro" 44 | ) 45 | if (System.getenv("RELEASE_STORE_FILE") != null) { 46 | signingConfig = signingConfigs["release"] 47 | } 48 | } 49 | } 50 | compileOptions { 51 | sourceCompatibility = JavaVersion.VERSION_11 52 | targetCompatibility = JavaVersion.VERSION_11 53 | } 54 | kotlinOptions { 55 | jvmTarget = "11" 56 | } 57 | buildFeatures { 58 | compose = true 59 | } 60 | composeOptions { 61 | kotlinCompilerExtensionVersion = "1.5.14" 62 | } 63 | packaging { 64 | resources { 65 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 66 | } 67 | } 68 | } 69 | 70 | dependencies { 71 | implementation("androidx.core:core-ktx:1.7.0") 72 | implementation("androidx.compose.ui:ui:1.0.5") 73 | implementation(libs.core.ktx) 74 | implementation(libs.lifecycle.runtime.ktx) 75 | implementation(libs.activity.compose) 76 | implementation(platform(libs.compose.bom)) 77 | implementation(libs.ui) 78 | implementation(libs.material3) 79 | implementation(libs.androidx.navigation.runtime.ktx) 80 | implementation(libs.accompanist.systemuicontroller) 81 | implementation(libs.accompanist.permissions) 82 | implementation(libs.androidx.navigation.compose) 83 | implementation(libs.androidx.compose.animation) 84 | implementation(libs.coil.compose) 85 | implementation(libs.androidx.material.icons.extended) 86 | implementation(libs.kotlinx.serialization.json) 87 | implementation(libs.kotlinx.coroutines.android) 88 | implementation(libs.androidx.preference) 89 | implementation(libs.ktor.core) 90 | implementation(libs.ktor.cio) 91 | implementation(libs.taglib) 92 | implementation(libs.kotlin.onetimepassword) 93 | implementation(libs.datastore.preferences) 94 | implementation(libs.ui.tooling) //NOT RECOMMENDED 95 | implementation(libs.ui.tooling.preview) //NOT RECOMMENDED 96 | implementation("io.ktor:ktor-client-core:2.3.4") 97 | implementation("io.ktor:ktor-client-cio:2.3.4") 98 | implementation("io.ktor:ktor-client-content-negotiation:2.3.4") 99 | implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.4") 100 | } 101 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | # Don't warn about missing classes while running R8. (isMinifyEnabled) 24 | -dontwarn org.bouncycastle.jsse.BCSSLParameters 25 | -dontwarn org.bouncycastle.jsse.BCSSLSocket 26 | -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider 27 | -dontwarn org.conscrypt.Conscrypt$Version 28 | -dontwarn org.conscrypt.Conscrypt 29 | -dontwarn org.conscrypt.ConscryptHostnameVerifier 30 | -dontwarn org.openjsse.javax.net.ssl.SSLParameters 31 | -dontwarn org.openjsse.javax.net.ssl.SSLSocket 32 | -dontwarn org.openjsse.net.ssl.OpenJSSE 33 | -dontwarn org.slf4j.impl.StaticLoggerBinder -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 35 | 36 | 37 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 79 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/ExpandableOutlinedCard.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.activities.quicksearch.components 2 | 3 | import android.content.res.Configuration.UI_MODE_NIGHT_YES 4 | import androidx.compose.animation.AnimatedVisibility 5 | import androidx.compose.animation.animateContentSize 6 | import androidx.compose.animation.core.animateFloatAsState 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.outlined.ExpandLess 14 | import androidx.compose.material.icons.outlined.PermDeviceInformation 15 | import androidx.compose.material3.CardDefaults 16 | import androidx.compose.material3.FilledTonalIconButton 17 | import androidx.compose.material3.Icon 18 | import androidx.compose.material3.IconButtonDefaults 19 | import androidx.compose.material3.MaterialTheme 20 | import androidx.compose.material3.OutlinedCard 21 | import androidx.compose.material3.Text 22 | import androidx.compose.material3.surfaceColorAtElevation 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.getValue 25 | import androidx.compose.runtime.mutableStateOf 26 | import androidx.compose.runtime.saveable.rememberSaveable 27 | import androidx.compose.runtime.setValue 28 | import androidx.compose.ui.Alignment 29 | import androidx.compose.ui.Modifier 30 | import androidx.compose.ui.draw.rotate 31 | import androidx.compose.ui.graphics.vector.ImageVector 32 | import androidx.compose.ui.res.stringResource 33 | import androidx.compose.ui.text.font.FontWeight 34 | import androidx.compose.ui.tooling.preview.Preview 35 | import androidx.compose.ui.unit.dp 36 | import pl.lambada.songsync.R 37 | 38 | @Composable 39 | fun ExpandableOutlinedCard( 40 | modifier: Modifier = Modifier, 41 | isExpanded: Boolean = false, 42 | title: String, 43 | subtitle: String, 44 | icon: ImageVector, 45 | content: @Composable () -> Unit, 46 | ) { 47 | var expanded by rememberSaveable { mutableStateOf(isExpanded) } 48 | 49 | val animatedDegree = 50 | animateFloatAsState(targetValue = if (expanded) 0f else -180f, label = "Button Rotation") 51 | 52 | OutlinedCard( 53 | modifier = modifier.animateContentSize(), 54 | onClick = { expanded = !expanded }, 55 | colors = CardDefaults.outlinedCardColors( 56 | containerColor = MaterialTheme.colorScheme.secondaryContainer, 57 | ), 58 | shape = MaterialTheme.shapes.small 59 | ) { 60 | Row( 61 | modifier = Modifier 62 | .fillMaxWidth() 63 | .padding(8.dp), 64 | verticalAlignment = Alignment.CenterVertically, 65 | ) { 66 | Icon( 67 | modifier = Modifier.weight(0.1f), 68 | imageVector = icon, 69 | contentDescription = stringResource(R.string.song_lyrics), 70 | ) 71 | Column( 72 | modifier = Modifier 73 | .fillMaxWidth() 74 | .padding(6.dp) 75 | .weight(1f), 76 | ) { 77 | Text( 78 | text = title, 79 | style = MaterialTheme.typography.bodyMedium, 80 | fontWeight = FontWeight.Bold 81 | ) 82 | Text( 83 | text = subtitle, 84 | style = MaterialTheme.typography.bodySmall, 85 | color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.62f), 86 | fontWeight = FontWeight.Normal 87 | ) 88 | } 89 | FilledTonalIconButton( 90 | modifier = Modifier.size(24.dp), 91 | onClick = { expanded = !expanded }, 92 | colors = IconButtonDefaults.filledTonalIconButtonColors( 93 | containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp), 94 | ) 95 | ) { 96 | Icon( 97 | imageVector = Icons.Outlined.ExpandLess, 98 | contentDescription = null, 99 | tint = MaterialTheme.colorScheme.onPrimaryContainer, 100 | modifier = Modifier.rotate(animatedDegree.value) 101 | ) 102 | } 103 | } 104 | AnimatedVisibility(visible = expanded) { 105 | content() 106 | } 107 | } 108 | } 109 | 110 | @Composable 111 | @Preview 112 | @Preview(uiMode = UI_MODE_NIGHT_YES) 113 | private fun ExpandableElevatedCardPreview() { 114 | ExpandableOutlinedCard( 115 | title = "Title", subtitle = "Subtitle", content = { 116 | Text(text = "Content") 117 | }, icon = Icons.Outlined.PermDeviceInformation 118 | ) 119 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/QuickLyricsSongInfo.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.activities.quicksearch.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.rounded.MusicNote 11 | import androidx.compose.material3.CardDefaults 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.OutlinedCard 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.runtime.mutableStateOf 19 | import androidx.compose.runtime.remember 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.draw.clip 23 | import androidx.compose.ui.graphics.vector.ImageVector 24 | import androidx.compose.ui.res.stringResource 25 | import androidx.compose.ui.text.TextStyle 26 | import androidx.compose.ui.text.font.FontWeight 27 | import androidx.compose.ui.tooling.preview.Preview 28 | import androidx.compose.ui.unit.dp 29 | import coil.ImageLoader 30 | import coil.compose.AsyncImage 31 | import pl.lambada.songsync.R 32 | import pl.lambada.songsync.activities.quicksearch.QuickLyricsSearchActivity 33 | import pl.lambada.songsync.domain.model.SongInfo 34 | 35 | @Composable 36 | fun QuickLyricsSongInfo( 37 | modifier: Modifier = Modifier, 38 | songInfo: SongInfo, 39 | imageLoader: ImageLoader = QuickLyricsSearchActivity.activityImageLoader 40 | ) { 41 | 42 | val imageUrl: String? by remember(songInfo.albumCoverLink) { 43 | mutableStateOf(songInfo.albumCoverLink) 44 | } 45 | 46 | OutlinedCard( 47 | modifier = modifier, 48 | colors = CardDefaults.outlinedCardColors().copy( 49 | containerColor = MaterialTheme.colorScheme.surface 50 | ), 51 | border = CardDefaults.outlinedCardBorder().copy( 52 | width = 2.dp, 53 | ) 54 | ) { 55 | Row( 56 | modifier = Modifier 57 | .fillMaxWidth() 58 | .padding(8.dp), 59 | horizontalArrangement = Arrangement.spacedBy(16.dp), 60 | verticalAlignment = Alignment.CenterVertically 61 | ) { 62 | AsyncImage( 63 | modifier = Modifier 64 | .size(72.dp) 65 | .clip(MaterialTheme.shapes.small), 66 | model = imageUrl, 67 | contentDescription = stringResource(R.string.album_cover), 68 | imageLoader = imageLoader, 69 | ) 70 | Column( 71 | modifier = Modifier, 72 | verticalArrangement = Arrangement.spacedBy(6.dp), 73 | horizontalAlignment = Alignment.Start 74 | ) { 75 | Text( 76 | text = songInfo.songName ?: stringResource(R.string.unknown), 77 | style = MaterialTheme.typography.bodyLarge.copy( 78 | fontWeight = FontWeight.SemiBold 79 | ) 80 | ) 81 | 82 | Text( 83 | text = songInfo.artistName ?: stringResource(R.string.unknown), 84 | style = MaterialTheme.typography.bodyMedium 85 | ) 86 | } 87 | } 88 | } 89 | } 90 | 91 | @Composable 92 | private fun TextWithIcon( 93 | icon: ImageVector, 94 | text: String, 95 | textStyle: TextStyle = MaterialTheme.typography.bodyLarge 96 | ) { 97 | Row( 98 | verticalAlignment = Alignment.CenterVertically, 99 | horizontalArrangement = Arrangement.spacedBy(4.dp) 100 | ) { 101 | Icon( 102 | imageVector = icon, 103 | contentDescription = null 104 | ) 105 | Text(text = text, style = textStyle) 106 | } 107 | } 108 | 109 | @Preview 110 | @Composable 111 | private fun TextWithIconPreview() { 112 | TextWithIcon( 113 | icon = Icons.Rounded.MusicNote, 114 | text = "Song Name" 115 | ) 116 | } 117 | 118 | @Preview 119 | @Composable 120 | private fun QuickLyricsSongInfoPreview() { 121 | QuickLyricsSongInfo( 122 | songInfo = SongInfo( 123 | songName = "Song Name", 124 | artistName = "Artist Name", 125 | albumCoverLink = "https://example.com/image.jpg" 126 | ) 127 | ) 128 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/SquaredButton.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.activities.quicksearch.components 2 | 3 | import androidx.compose.animation.core.animateFloatAsState 4 | import androidx.compose.foundation.BorderStroke 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.defaultMinSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.foundation.shape.CornerBasedShape 13 | import androidx.compose.foundation.shape.RoundedCornerShape 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.filled.Home 16 | import androidx.compose.material.icons.filled.Settings 17 | import androidx.compose.material3.ButtonDefaults 18 | import androidx.compose.material3.Icon 19 | import androidx.compose.material3.MaterialTheme 20 | import androidx.compose.material3.Surface 21 | import androidx.compose.material3.Text 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.runtime.getValue 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.draw.alpha 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.graphics.vector.ImageVector 29 | import androidx.compose.ui.semantics.Role 30 | import androidx.compose.ui.semantics.role 31 | import androidx.compose.ui.semantics.semantics 32 | import androidx.compose.ui.text.font.FontWeight 33 | import androidx.compose.ui.text.style.TextOverflow 34 | import androidx.compose.ui.tooling.preview.Preview 35 | import androidx.compose.ui.unit.dp 36 | 37 | @Composable 38 | fun SquareButtons() { 39 | Row( 40 | modifier = Modifier 41 | .fillMaxWidth() 42 | .padding(16.dp) 43 | ) { 44 | ButtonWithIconAndText( 45 | icon = Icons.Default.Home, 46 | text = "Home", 47 | modifier = Modifier.size(96.dp), 48 | shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp) 49 | ) 50 | ButtonWithIconAndText( 51 | icon = Icons.Default.Settings, 52 | text = "Settings", 53 | modifier = Modifier.size(96.dp), 54 | shape = RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp) 55 | ) 56 | } 57 | } 58 | 59 | @Composable 60 | fun ButtonWithIconAndText( 61 | modifier: Modifier = Modifier, 62 | icon: ImageVector, 63 | text: String, 64 | enabled: Boolean = true, 65 | border: Boolean = false, 66 | backgroundColor: Color = MaterialTheme.colorScheme.secondaryContainer, 67 | shape: CornerBasedShape = MaterialTheme.shapes.small, 68 | onClick: () -> Unit = {} 69 | ) { 70 | 71 | val animatedAlpha by animateFloatAsState( 72 | targetValue = if (enabled) 1f else 0.4f 73 | ) 74 | 75 | Surface( 76 | modifier = modifier 77 | .semantics { role = Role.Button } 78 | .alpha(animatedAlpha), 79 | onClick = onClick, 80 | enabled = enabled, 81 | shape = shape, 82 | border = if (border) BorderStroke(1.dp, MaterialTheme.colorScheme.outline) else null, 83 | color = backgroundColor 84 | ) { 85 | Column( 86 | horizontalAlignment = Alignment.CenterHorizontally, 87 | verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), 88 | modifier = Modifier 89 | .padding(8.dp) 90 | .defaultMinSize( 91 | minWidth = ButtonDefaults.MinWidth, 92 | minHeight = ButtonDefaults.MinHeight 93 | ) 94 | .fillMaxWidth() 95 | ) { 96 | Icon( 97 | imageVector = icon, 98 | contentDescription = null, 99 | tint = MaterialTheme.colorScheme.onSurface 100 | ) 101 | Text( 102 | text = text, 103 | style = MaterialTheme.typography.bodySmall.copy( 104 | fontWeight = FontWeight.Medium, 105 | ), 106 | color = MaterialTheme.colorScheme.onSurface, 107 | overflow = TextOverflow.Ellipsis, 108 | ) 109 | } 110 | } 111 | } 112 | 113 | @Preview 114 | @Composable 115 | fun SquareButtonsPreview() { 116 | SquareButtons() 117 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/activities/quicksearch/components/SyncedLyricsLine.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.activities.quicksearch.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.width 8 | import androidx.compose.foundation.rememberScrollState 9 | import androidx.compose.foundation.verticalScroll 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.text.SpanStyle 15 | import androidx.compose.ui.text.buildAnnotatedString 16 | import androidx.compose.ui.text.font.FontFamily 17 | import androidx.compose.ui.text.withStyle 18 | import androidx.compose.ui.unit.dp 19 | import androidx.compose.ui.unit.sp 20 | 21 | @Composable 22 | fun SyncedLyricsLine( 23 | time: String, 24 | lyrics: String, 25 | modifier: Modifier = Modifier 26 | ) { 27 | Row(modifier = modifier) { 28 | val formattedTime = buildAnnotatedString { 29 | append(time.substring(0, time.length - 4)) 30 | withStyle( 31 | style = SpanStyle( 32 | fontSize = 12.sp, 33 | color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) 34 | ) 35 | ) { 36 | append(time.takeLast(4)) 37 | } 38 | } 39 | Text( 40 | text = formattedTime, 41 | style = MaterialTheme.typography.bodyMedium.copy( 42 | fontFamily = FontFamily.Monospace 43 | ) 44 | ) 45 | Spacer(modifier = Modifier.width(8.dp)) 46 | Text( 47 | text = lyrics, 48 | style = MaterialTheme.typography.bodyMedium 49 | ) 50 | } 51 | } 52 | 53 | @Composable 54 | fun SyncedLyricsColumn( 55 | lyricsList: List>, 56 | modifier: Modifier = Modifier 57 | ) { 58 | Column( 59 | modifier = modifier.verticalScroll(rememberScrollState()) 60 | ) { 61 | lyricsList.forEach { (time, lyrics) -> 62 | SyncedLyricsLine(time = time, lyrics = lyrics) 63 | Spacer(modifier = Modifier.height(4.dp)) 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/activities/quicksearch/viewmodel/QuickLyricsSearchViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.activities.quicksearch.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import pl.lambada.songsync.data.UserSettingsController 6 | import pl.lambada.songsync.data.remote.lyrics_providers.LyricsProviderService 7 | 8 | class QuickLyricsSearchViewModelFactory( 9 | private val userSettingsController: UserSettingsController, 10 | private val lyricsProviderService: LyricsProviderService 11 | ) : ViewModelProvider.Factory { 12 | override fun create(modelClass: Class): T { 13 | if (modelClass.isAssignableFrom(QuickLyricsSearchViewModel::class.java)) { 14 | @Suppress("UNCHECKED_CAST") 15 | return QuickLyricsSearchViewModel(userSettingsController, lyricsProviderService) as T 16 | } 17 | throw IllegalArgumentException("Unknown ViewModel class") 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/data/remote/PaxMusicHelper.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.data.remote 2 | 3 | import pl.lambada.songsync.domain.model.lyrics_providers.PaxLyrics 4 | import pl.lambada.songsync.domain.model.lyrics_providers.PaxResponse 5 | import pl.lambada.songsync.util.ext.toLrcTimestamp 6 | import pl.lambada.songsync.util.networking.Ktor.json 7 | 8 | class PaxMusicHelper { 9 | 10 | /** 11 | * Formats syllable lyrics into LRC format. 12 | * @param lyrics The list of PaxLyrics objects. 13 | * @param multiPersonWordByWord Flag to format lyrics for multiple persons word by word. 14 | * @return The formatted lyrics as a string. 15 | */ 16 | private fun formatSyllableLyrics(lyrics: List, multiPersonWordByWord: Boolean): String { 17 | val syncedLyrics = StringBuilder() 18 | for (line in lyrics) { 19 | syncedLyrics.append("[${line.timestamp.toLrcTimestamp()}]") 20 | 21 | if (multiPersonWordByWord) { 22 | syncedLyrics.append( 23 | if (line.oppositeTurn) "v2:" else "v1:" 24 | ) 25 | } 26 | 27 | for (syllable in line.text) { 28 | val formatedBeginTimestamp = "<${syllable.timestamp!!.toLrcTimestamp()}>" 29 | val formatedEndTimestamp = "<${syllable.endtime?.toLrcTimestamp()}>" 30 | if (!syncedLyrics.endsWith(formatedBeginTimestamp)) 31 | syncedLyrics.append(formatedBeginTimestamp) 32 | syncedLyrics.append(syllable.text) 33 | if (!syllable.part) 34 | syncedLyrics.append(" ") 35 | syncedLyrics.append(formatedEndTimestamp) 36 | } 37 | 38 | if (line.background && multiPersonWordByWord) { 39 | syncedLyrics.append("\n[bg:") 40 | for (syllable in line.backgroundText) { 41 | val formatedBeginTimestamp = "<${syllable.timestamp!!.toLrcTimestamp()}>" 42 | val formatedEndTimestamp = "<${syllable.endtime?.toLrcTimestamp()}>" 43 | if (!syncedLyrics.endsWith(formatedBeginTimestamp)) 44 | syncedLyrics.append(formatedBeginTimestamp) 45 | syncedLyrics.append(syllable.text) 46 | if (!syllable.part) 47 | syncedLyrics.append(" ") 48 | syncedLyrics.append(formatedEndTimestamp) 49 | } 50 | syncedLyrics.append("]") 51 | } 52 | syncedLyrics.append("\n") 53 | } 54 | return syncedLyrics.toString() 55 | } 56 | 57 | /** 58 | * Formats line lyrics into LRC format. 59 | * @param lyrics The list of PaxLyrics objects. 60 | * @return The formatted lyrics as a string. 61 | */ 62 | private fun formatLineLyrics(lyrics: List): String { 63 | val syncedLyrics = StringBuilder() 64 | for (line in lyrics) { 65 | syncedLyrics.append("[${line.timestamp.toLrcTimestamp()}]${line.text[0].text}\n") 66 | } 67 | return syncedLyrics.toString() 68 | } 69 | 70 | /** 71 | * Formats word-by-word lyrics into LRC format. 72 | * @param apiResponse The API response containing the lyrics. 73 | * @param multiPersonWordByWord Flag to format lyrics for multiple persons word by word. 74 | * @return The formatted lyrics as a string or null if the lyrics were not found. 75 | */ 76 | fun formatWordByWordLyrics(apiResponse: String, multiPersonWordByWord: Boolean): String? { 77 | return try { 78 | val data = json.decodeFromString(apiResponse) 79 | if (data.content!!.isEmpty()) 80 | return null 81 | 82 | val lines = data.content 83 | 84 | when (data.type) { 85 | "Syllable" -> formatSyllableLyrics(lines, multiPersonWordByWord).dropLast(1) 86 | "Line" -> formatLineLyrics(lines).dropLast(1) 87 | else -> null 88 | } 89 | } catch (e: Exception) { 90 | val data = json.decodeFromString>(apiResponse) 91 | formatSyllableLyrics(data, multiPersonWordByWord) 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/data/remote/UpdateService.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.data.remote 2 | 3 | import android.content.Context 4 | import kotlinx.coroutines.flow.flow 5 | import pl.lambada.songsync.data.remote.github.GithubAPI 6 | import pl.lambada.songsync.domain.model.Release 7 | import pl.lambada.songsync.util.ext.getVersion 8 | 9 | class UpdateService { 10 | 11 | /** 12 | * Checks for updates by comparing the latest release version with the current version. 13 | * @param context The context of the application. 14 | * @return A flow emitting the update state. 15 | */ 16 | fun checkForUpdates(context: Context) = flow { 17 | emit(UpdateState.Checking) 18 | 19 | try { 20 | val latest = GithubAPI.getLatestRelease() 21 | val isUpdate = isNewerRelease(context, latest) 22 | 23 | emit( 24 | if (isUpdate) 25 | UpdateState.UpdateAvailable(latest) 26 | else 27 | UpdateState.UpToDate 28 | ) 29 | 30 | } catch (e: Exception) { 31 | emit(UpdateState.Error(e)) 32 | } 33 | } 34 | 35 | /** 36 | * Checks if the latest release is newer than the current version. 37 | * @param context The context of the application. 38 | * @param latestRelease The latest release from the GitHub API. 39 | * @return True if the latest release is newer, false otherwise. 40 | */ 41 | private fun isNewerRelease(context: Context, latestRelease: Release): Boolean { 42 | val currentVersion = context 43 | .getVersion() 44 | .replace(".", "") 45 | .toInt() 46 | val latestVersion = latestRelease.tagName 47 | .replace(".", "") 48 | .replace("v", "") 49 | .toInt() 50 | 51 | return latestVersion > currentVersion 52 | } 53 | } 54 | 55 | /** 56 | * Defines the state of the update check. 57 | */ 58 | sealed interface UpdateState { 59 | data object Idle : UpdateState 60 | data object Checking : UpdateState 61 | data object UpToDate : UpdateState 62 | data class UpdateAvailable(val release: Release) : UpdateState 63 | data class Error(val reason: Throwable) : UpdateState 64 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/data/remote/github/GithubAPI.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.data.remote.github 2 | 3 | import io.ktor.client.request.get 4 | import io.ktor.client.statement.bodyAsText 5 | import pl.lambada.songsync.domain.model.Release 6 | import pl.lambada.songsync.util.networking.Ktor.client 7 | import pl.lambada.songsync.util.networking.Ktor.json 8 | 9 | 10 | object GithubAPI { 11 | private const val BASE_URL = "https://api.github.com/" 12 | 13 | /** 14 | * Gets latest GitHub release information. 15 | * @return The latest release version. 16 | */ 17 | suspend fun getLatestRelease(): Release { 18 | val response = client.get(BASE_URL + "repos/Lambada10/SongSync/releases/latest") 19 | val responseBody = response.bodyAsText(Charsets.UTF_8) 20 | 21 | return json.decodeFromString(responseBody) 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/AppleAPI.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.data.remote.lyrics_providers.others 2 | 3 | import io.ktor.client.request.get 4 | import io.ktor.client.statement.bodyAsText 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.withContext 7 | import pl.lambada.songsync.data.remote.PaxMusicHelper 8 | import pl.lambada.songsync.domain.model.SongInfo 9 | import pl.lambada.songsync.domain.model.lyrics_providers.others.AppleSearchResponse 10 | import pl.lambada.songsync.util.EmptyQueryException 11 | import pl.lambada.songsync.util.networking.Ktor.client 12 | import pl.lambada.songsync.util.networking.Ktor.json 13 | import java.net.URLEncoder 14 | 15 | class AppleAPI { 16 | private val baseURL = "https://paxsenix.alwaysdata.net/" 17 | 18 | /** 19 | * Searches for song information using the song name and artist name. 20 | * @param query The SongInfo object with songName and artistName fields filled. 21 | * @param offset The offset used for trying to find a better match or searching again. 22 | * @return Search result as a SongInfo object. 23 | */ 24 | suspend fun getSongInfo(query: SongInfo, offset: Int = 0): SongInfo? { 25 | val search = withContext(Dispatchers.IO) { 26 | URLEncoder.encode( 27 | "${query.songName} ${query.artistName}", 28 | Charsets.UTF_8.toString() 29 | ) 30 | } 31 | 32 | if (search.isBlank()) 33 | throw EmptyQueryException() 34 | 35 | val response = client.get( 36 | baseURL + "searchAppleMusic.php?q=$search" 37 | ) 38 | val responseBody = response.bodyAsText(Charsets.UTF_8) 39 | 40 | if (response.status.value !in 200..299) 41 | return null 42 | 43 | val json = try { 44 | json.decodeFromString>(responseBody) 45 | } catch (e: Exception) { 46 | return null 47 | } 48 | 49 | val result = try { 50 | json[offset] 51 | } catch (e: IndexOutOfBoundsException) { 52 | return null 53 | } 54 | 55 | return SongInfo( 56 | songName = result.songName, 57 | artistName = result.artistName, 58 | songLink = result.url, 59 | albumCoverLink = result.artwork.replace("{w}", "100").replace("{h}", "100") 60 | .replace("{f}", "png"), 61 | appleID = result.id 62 | ) 63 | } 64 | 65 | /** 66 | * Gets synced lyrics using the song ID and returns them as a string formatted as an LRC file. 67 | * @param id The ID of the song from search results. 68 | * @param multiPersonWordByWord Flag to format lyrics for multiple persons word by word. 69 | * @return The synced lyrics as a string. 70 | */ 71 | suspend fun getSyncedLyrics(id: Long, multiPersonWordByWord: Boolean): String? { 72 | val response = client.get( 73 | baseURL + "getAppleMusicLyrics.php?id=$id" 74 | ) 75 | val responseBody = response.bodyAsText(Charsets.UTF_8) 76 | 77 | if (response.status.value !in 200..299 || responseBody.isEmpty()) 78 | return null 79 | 80 | return PaxMusicHelper().formatWordByWordLyrics(responseBody, multiPersonWordByWord) 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/LRCLibAPI.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.data.remote.lyrics_providers.others 2 | 3 | import io.ktor.client.request.get 4 | import io.ktor.client.statement.bodyAsText 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.withContext 7 | import pl.lambada.songsync.domain.model.SongInfo 8 | import pl.lambada.songsync.domain.model.lyrics_providers.others.LRCLibResponse 9 | import pl.lambada.songsync.util.EmptyQueryException 10 | import pl.lambada.songsync.util.networking.Ktor.client 11 | import pl.lambada.songsync.util.networking.Ktor.json 12 | import java.net.URLEncoder 13 | import java.nio.charset.StandardCharsets 14 | 15 | class LRCLibAPI { 16 | private val baseURL = "https://lrclib.net/api/" 17 | 18 | /** 19 | * Searches for synced lyrics using the song name and artist name. 20 | * @param query The SongInfo object with songName and artistName fields filled. 21 | * @return Search result as a SongInfo object. 22 | */ 23 | suspend fun getSongInfo(query: SongInfo, offset: Int = 0): SongInfo? { 24 | val search = withContext(Dispatchers.IO) { 25 | URLEncoder.encode( 26 | "${query.songName} ${query.artistName}", 27 | StandardCharsets.UTF_8.toString() 28 | ) 29 | } 30 | 31 | if (search == "+") 32 | throw EmptyQueryException() 33 | 34 | val response = client.get( 35 | baseURL + "search?q=$search" 36 | ) 37 | val responseBody = response.bodyAsText(Charsets.UTF_8) 38 | 39 | if (responseBody == "[]" || response.status.value !in 200..299) 40 | return null 41 | 42 | val json = json.decodeFromString>(responseBody) 43 | 44 | val song = try { 45 | json[offset] 46 | } catch (e: IndexOutOfBoundsException) { 47 | return null 48 | } 49 | 50 | return SongInfo( 51 | songName = song.trackName, 52 | artistName = song.artistName, 53 | lrcLibID = song.id 54 | ) 55 | } 56 | 57 | /** 58 | * Searches for synced lyrics using the song name and artist name. 59 | * @param id The ID of the song from search results. 60 | * @return The synced lyrics as a string. 61 | */ 62 | suspend fun getSyncedLyrics(id: Int): String? { 63 | val response = client.get( 64 | baseURL + "get/$id" 65 | ) 66 | val responseBody = response.bodyAsText(Charsets.UTF_8) 67 | 68 | if (response.status.value !in 200..299 || responseBody == "[]") 69 | return null 70 | 71 | val json = json.decodeFromString(responseBody) 72 | return json.syncedLyrics 73 | } 74 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/MusixmatchAPI.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.data.remote.lyrics_providers.others 2 | 3 | import io.ktor.client.request.get 4 | import io.ktor.client.statement.bodyAsText 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.withContext 7 | import pl.lambada.songsync.domain.model.SongInfo 8 | import pl.lambada.songsync.domain.model.lyrics_providers.others.MusixmatchSearchResponse 9 | import pl.lambada.songsync.util.EmptyQueryException 10 | import pl.lambada.songsync.util.networking.Ktor.client 11 | import pl.lambada.songsync.util.networking.Ktor.json 12 | import java.net.URLEncoder 13 | 14 | class MusixmatchAPI { 15 | private val baseURL = "https://kerollosy.vercel.app" 16 | 17 | /** 18 | * Searches for synced lyrics using the song name and artist name. 19 | * @param query The SongInfo object with songName and artistName fields filled. 20 | * @return Search result as a SongInfo object. 21 | */ 22 | suspend fun getSongInfo(query: SongInfo, offset: Int = 0): SongInfo? { 23 | val artistName = withContext(Dispatchers.IO) { 24 | URLEncoder.encode( 25 | query.artistName, 26 | Charsets.UTF_8.toString() 27 | ) 28 | } 29 | 30 | val songName = withContext(Dispatchers.IO) { 31 | URLEncoder.encode( 32 | query.songName, 33 | Charsets.UTF_8.toString() 34 | ) 35 | } 36 | 37 | if (artistName.isBlank() || songName.isBlank()) 38 | throw EmptyQueryException() 39 | 40 | val response = client.get( 41 | "$baseURL/full?artist=$artistName&track=$songName" 42 | ) 43 | val responseBody = response.bodyAsText(Charsets.UTF_8) 44 | 45 | if (response.status.value !in 200..299) 46 | return null 47 | 48 | val result = json.decodeFromString(responseBody) 49 | 50 | return SongInfo( 51 | songName = result.songName, 52 | artistName = result.artistName, 53 | songLink = result.url, 54 | albumCoverLink = result.artwork, 55 | musixmatchID = result.id, 56 | hasSyncedLyrics = result.hasSyncedLyrics, 57 | hasUnsyncedLyrics = result.hasSyncedLyrics, 58 | syncedLyrics = result.syncedLyrics?.lyrics, 59 | unsyncedLyrics = result.unsyncedLyrics?.lyrics 60 | ) 61 | } 62 | 63 | /** 64 | * Returns the lyrics. 65 | * @param songInfo The SongInfo of the song from search results. 66 | * @param preferUnsynced Flag to prefer unsynced lyrics when synced lyrics are not available. 67 | * @return The lyrics as a string or null if the lyrics were not found. 68 | */ 69 | fun getLyrics(songInfo: SongInfo?, preferUnsynced: Boolean = true): String? { 70 | return songInfo?.syncedLyrics ?: if (preferUnsynced) songInfo?.unsyncedLyrics else null 71 | } 72 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/QQMusicAPI.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.data.remote.lyrics_providers.others 2 | 3 | import io.ktor.client.request.get 4 | import io.ktor.client.request.header 5 | import io.ktor.client.request.parameter 6 | import io.ktor.client.request.post 7 | import io.ktor.client.request.setBody 8 | import io.ktor.client.statement.bodyAsText 9 | import pl.lambada.songsync.data.remote.PaxMusicHelper 10 | import pl.lambada.songsync.domain.model.SongInfo 11 | import pl.lambada.songsync.domain.model.lyrics_providers.others.PaxQQPayload 12 | import pl.lambada.songsync.domain.model.lyrics_providers.others.QQMusicSearchResponse 13 | import pl.lambada.songsync.util.EmptyQueryException 14 | import pl.lambada.songsync.util.networking.Ktor.client 15 | import pl.lambada.songsync.util.networking.Ktor.json 16 | 17 | class QQMusicAPI { 18 | private val baseURL = "https://c.y.qq.com/soso/fcgi-bin/client_search_cp" 19 | private val lyricsURL = "https://paxsenix.alwaysdata.net/getQQLyrics.php" 20 | 21 | private val reqHeaders = mapOf( 22 | "Content-Type" to "application/json", 23 | "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" 24 | ) 25 | 26 | private val reqParams = mapOf( 27 | "format" to "json", 28 | "inCharset" to "utf8", 29 | "outCharset" to "utf8", 30 | "platform" to "yqq.json" 31 | ) 32 | 33 | /** 34 | * Searches for synced lyrics using the song name and artist name. 35 | * @param query The SongInfo object with songName and artistName fields filled. 36 | * @return Search result as a SongInfo object. 37 | */ 38 | suspend fun getSongInfo(query: SongInfo, offset: Int = 0): SongInfo? { 39 | val search = "${query.songName} ${query.artistName}" 40 | 41 | if (search.isBlank()) 42 | throw EmptyQueryException() 43 | 44 | val response = client.get(baseURL) { 45 | reqHeaders.forEach { 46 | header(it.key, it.value) 47 | } 48 | reqParams.forEach { 49 | parameter(it.key, it.value) 50 | } 51 | parameter("new_json", 1) 52 | parameter("w", search) 53 | } 54 | val responseBody = response.bodyAsText(Charsets.UTF_8) 55 | 56 | if (response.status.value !in 200..299) 57 | return null 58 | 59 | val result = json.decodeFromString(responseBody) 60 | 61 | val song = try { 62 | result.data.song.list[offset] 63 | } catch (e: IndexOutOfBoundsException) { 64 | return null 65 | } 66 | 67 | val artists = song.singer.joinToString(", ") { it.name } 68 | 69 | return SongInfo( 70 | songName = song.title, 71 | artistName = artists, 72 | qqPayload = json.encodeToString( 73 | PaxQQPayload.serializer(), 74 | PaxQQPayload( 75 | artist = song.singer.map { it.name }, 76 | album = song.album.name, 77 | id = song.id, 78 | title = song.title 79 | ) 80 | ) 81 | ) 82 | } 83 | 84 | /** 85 | * Searches for synced lyrics using the song name and artist name. 86 | * @param payload The payload generated from search results. 87 | * @return The synced lyrics as a string. 88 | */ 89 | suspend fun getSyncedLyrics(payload: String, multiPersonWordByWord: Boolean = false): String? { 90 | val response = client.post(lyricsURL) { 91 | setBody(payload) 92 | } 93 | val responseBody = response.bodyAsText(Charsets.UTF_8) 94 | 95 | return PaxMusicHelper().formatWordByWordLyrics(responseBody, multiPersonWordByWord) 96 | } 97 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/spotify/SpotifyLyricsAPI.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.data.remote.lyrics_providers.spotify 2 | 3 | import io.ktor.client.request.get 4 | import io.ktor.client.request.parameter 5 | import io.ktor.client.statement.bodyAsText 6 | import pl.lambada.songsync.domain.model.lyrics_providers.spotify.SyncedLinesResponse 7 | import pl.lambada.songsync.util.networking.Ktor.client 8 | import pl.lambada.songsync.util.networking.Ktor.json 9 | 10 | class SpotifyLyricsAPI { 11 | private val baseURL = "https://lyrichub.echoir.workers.dev/api/spotify" 12 | 13 | /** 14 | * Gets synced lyrics using the song link and returns them as a string formatted as an LRC file. 15 | * @param title The title of the song. 16 | * @param artist The name of the artist. 17 | * @return The synced lyrics as a string. 18 | */ 19 | suspend fun getSyncedLyrics(title: String, artist: String): String? { 20 | val response = client.get(baseURL) { 21 | parameter("query", "$title $artist") 22 | } 23 | val responseBody = response.bodyAsText(Charsets.UTF_8) 24 | if (response.status.value !in 200..299) 25 | return null 26 | 27 | val json = json.decodeFromString(responseBody) 28 | 29 | if (json.lyrics == "Not Found.") 30 | return null 31 | 32 | return json.lyrics 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/Release.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | 8 | @Parcelize 9 | @Serializable 10 | data class Release( 11 | @SerialName("html_url") 12 | val htmlURL: String, 13 | @SerialName("tag_name") 14 | val tagName: String, 15 | @SerialName("body") 16 | val changelog: String? = null 17 | ) : Parcelable 18 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/Song.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model 2 | 3 | import android.net.Uri 4 | import android.os.Parcelable 5 | import kotlinx.parcelize.Parcelize 6 | 7 | /** 8 | * Data class for representing a song. 9 | * @param title The title of the song. 10 | * @param artist The artist of the song. 11 | * @param imgUri The URI of the image. 12 | * @param filePath The file path of the song. 13 | */ 14 | @Parcelize 15 | data class Song( 16 | val title: String?, 17 | val artist: String?, 18 | val imgUri: Uri?, 19 | val filePath: String? 20 | ) : Parcelable -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/SongInfo.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | /** 7 | * Data class for storing song information. 8 | * Used for both local and remote songs. 9 | * The only difference is that local songs have songLink set to null. 10 | * @param songName The name of the song. 11 | * @param artistName The name of the artist. 12 | * @param songLink The link to the song. 13 | * @param albumCoverLink The link to the album cover. 14 | * @param lrcLibID The ID for LRCLib. 15 | * @param qqPayload The payload for QQMusic. 16 | * @param neteaseID The ID for Netease. 17 | * @param appleID The ID for Apple Music. 18 | * @param musixmatchID The ID for Musixmatch. 19 | * @param hasSyncedLyrics Flag indicating if the song has synced lyrics (Musixmatch-only). 20 | * @param hasUnsyncedLyrics Flag indicating if the song has unsynced lyrics (Musixmatch-only). 21 | * @param syncedLyrics The synced lyrics (Musixmatch-only). 22 | * @param unsyncedLyrics The unsynced lyrics (Musixmatch-only). 23 | */ 24 | @Suppress("SpellCheckingInspection") 25 | @Parcelize 26 | data class SongInfo( 27 | var songName: String?, 28 | var artistName: String? = null, 29 | var songLink: String? = null, 30 | var albumCoverLink: String? = null, 31 | var lrcLibID: Int? = null, // LRCLib-only 32 | var qqPayload: String? = null, // QQMusic-only 33 | var neteaseID: Long? = null, // Netease-only 34 | var appleID: Long? = null, // Apple-only 35 | var musixmatchID: Long? = null, // Musixmatch-only 36 | var hasSyncedLyrics: Boolean? = null, // Musixmatch-only 37 | var hasUnsyncedLyrics: Boolean? = null, // Musixmatch-only 38 | var syncedLyrics: String? = null, // Musixmatch-only 39 | var unsyncedLyrics: String? = null, // Musixmatch-only 40 | ) : Parcelable -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/Sort.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model 2 | 3 | import androidx.annotation.StringRes 4 | import pl.lambada.songsync.R 5 | 6 | /** 7 | * Enum class representing sort orders. 8 | * @param queryName The query name for the sort order. 9 | * @param displayName The string resource ID for the display name. 10 | */ 11 | enum class SortOrders(val queryName: String, @StringRes val displayName: Int) { 12 | ASCENDING("ASC", R.string.ascending), 13 | DESCENDING("DESC", R.string.descending), 14 | } 15 | 16 | /** 17 | * Enum class representing sort values. 18 | * @param displayName The string resource ID for the display name. 19 | */ 20 | enum class SortValues(@StringRes val displayName: Int) { 21 | TITLE(R.string.title), 22 | ARTIST(R.string.artist), 23 | ALBUM(R.string.album), 24 | YEAR(R.string.year), 25 | DURATION(R.string.duration), 26 | DATE_ADDED(R.string.date_added), 27 | DATE_MODIFIED(R.string.date_modified), 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/PaxMusicFormat.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model.lyrics_providers 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class PaxResponse( 7 | val type: String, 8 | val content: List? 9 | ) 10 | 11 | @Serializable 12 | data class PaxLyrics( 13 | val text: List, 14 | val timestamp: Int, 15 | val oppositeTurn: Boolean, 16 | val background: Boolean, 17 | val backgroundText: List, 18 | val endtime: Int 19 | ) 20 | 21 | @Serializable 22 | data class PaxLyricsLineDetails( 23 | val text: String, 24 | val part: Boolean, 25 | val timestamp: Int?, 26 | val endtime: Int? 27 | ) -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/Apple.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model.lyrics_providers.others 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class AppleSearchResponse( 7 | val id: Long, 8 | val songName: String, 9 | val artistName: String, 10 | val albumName: String, 11 | val artwork: String, 12 | val releaseDate: String?, 13 | val duration: Int, 14 | val isrc: String, 15 | val url: String, 16 | val contentRating: String?, 17 | val albumId: String 18 | ) -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/LRCLib.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model.lyrics_providers.others 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class LRCLibResponse( 7 | val id: Int, 8 | val name: String, 9 | val trackName: String, 10 | val artistName: String, 11 | val albumName: String, 12 | val duration: Double, 13 | val instrumental: Boolean, 14 | val plainLyrics: String?, 15 | val syncedLyrics: String? 16 | ) -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/Musixmatch.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model.lyrics_providers.others 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class MusixmatchSearchResponse( 7 | val id: Long, 8 | val songName: String, 9 | val artistName: String, 10 | val albumName: String, 11 | val artwork: String, 12 | val releaseDate: String, 13 | val duration: Int, 14 | val url: String, 15 | val albumId: Long, 16 | val hasSyncedLyrics: Boolean, 17 | val hasUnsyncedLyrics: Boolean, 18 | val syncedLyrics: SyncedLyricsResponse? = null, 19 | val unsyncedLyrics: UnsyncedLyricsResponse? = null 20 | ) 21 | 22 | @Serializable 23 | data class SyncedLyricsResponse( 24 | val id: Long, 25 | val duration: Int, 26 | val language: String, 27 | val updatedTime: String, 28 | val lyrics: String 29 | ) 30 | 31 | @Serializable 32 | data class UnsyncedLyricsResponse( 33 | val id: Long, 34 | val language: String, 35 | val updatedTime: String, 36 | val lyrics: String, 37 | ) 38 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/Netease.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model.lyrics_providers.others 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class NeteaseResponse( 7 | val result: NeteaseResult, 8 | val code: Int 9 | ) 10 | 11 | @Serializable 12 | data class NeteaseResult( 13 | val songs: List, 14 | val songCount: Int 15 | ) 16 | 17 | @Serializable 18 | data class NeteaseSong( 19 | val name: String, 20 | val id: Long, 21 | val artists: List, 22 | ) 23 | 24 | @Serializable 25 | data class NeteaseArtist( 26 | val name: String 27 | ) 28 | 29 | @Serializable 30 | data class NeteaseLyricsResponse( 31 | val lrc: NeteaseLyrics, 32 | val tlyric: NeteaseLyrics?, 33 | val romalrc: NeteaseLyrics?, 34 | val code: Int 35 | ) 36 | 37 | @Serializable 38 | data class NeteaseLyrics( 39 | val lyric: String 40 | ) 41 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/QQMusic.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model.lyrics_providers.others 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class QQMusicSearchResponse( 7 | val data: QQMusicData 8 | ) 9 | 10 | @Serializable 11 | data class QQMusicData( 12 | val song: QQMusicSong 13 | ) 14 | 15 | @Serializable 16 | data class QQMusicSong( 17 | val list: List 18 | ) 19 | 20 | @Serializable 21 | data class QQMusicSongInfo( 22 | val title: String, 23 | val singer: List, 24 | val album: QQMusicAlbum, 25 | val id: Long 26 | ) 27 | 28 | @Serializable 29 | data class QQMusicSinger( 30 | val name: String 31 | ) 32 | 33 | @Serializable 34 | data class QQMusicAlbum( 35 | val name: String 36 | ) 37 | 38 | @Serializable 39 | data class PaxQQPayload( 40 | val artist: List, 41 | val album: String, 42 | val id: Long, 43 | val title: String 44 | ) -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/spotify/AccessToken.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model.lyrics_providers.spotify 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class AccessTokenResponse( 8 | @SerialName("access_token") 9 | val accessToken: String, 10 | @SerialName("token_type") 11 | val tokenType: String, 12 | @SerialName("expires_in") 13 | val expiresIn: Int 14 | ) 15 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/spotify/SpotifyApi.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model.lyrics_providers.spotify 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class TrackSearchResult( 8 | val tracks: Tracks 9 | ) 10 | 11 | @Serializable 12 | data class Tracks( 13 | val href: String, 14 | val items: List, 15 | val limit: Int, 16 | val next: String?, 17 | val offset: Int, 18 | val previous: String?, 19 | val total: Int 20 | ) 21 | 22 | @Serializable 23 | data class Track( 24 | val album: Album, 25 | val artists: List, 26 | @SerialName("available_markets") 27 | val availableMarkets: List, 28 | @SerialName("disc_number") 29 | val discNumber: Int, 30 | @SerialName("duration_ms") 31 | val durationMs: Int, 32 | val explicit: Boolean, 33 | @SerialName("external_ids") 34 | val externalIds: ExternalIds, 35 | @SerialName("external_urls") 36 | val externalUrls: ExternalUrls, 37 | val href: String, 38 | val id: String, 39 | @SerialName("is_local") 40 | val isLocal: Boolean, 41 | val name: String, 42 | val popularity: Int, 43 | @SerialName("preview_url") 44 | val previewUrl: String?, 45 | @SerialName("track_number") 46 | val trackNumber: Int, 47 | val type: String, 48 | val uri: String 49 | ) 50 | 51 | @Serializable 52 | data class Album( 53 | @SerialName("album_type") 54 | val albumType: String, 55 | val artists: List, 56 | @SerialName("available_markets") 57 | val availableMarkets: List, 58 | @SerialName("external_urls") 59 | val externalUrls: ExternalUrls, 60 | val href: String, 61 | val id: String, 62 | val images: List, 63 | val name: String, 64 | @SerialName("release_date") 65 | val releaseDate: String, 66 | @SerialName("release_date_precision") 67 | val releaseDatePrecision: String, 68 | @SerialName("total_tracks") 69 | val totalTracks: Int, 70 | val type: String, 71 | val uri: String 72 | ) 73 | 74 | @Serializable 75 | data class Artist( 76 | val externalUrls: ExternalUrls? = null, 77 | val href: String, 78 | val id: String, 79 | val name: String, 80 | val type: String, 81 | val uri: String 82 | ) 83 | 84 | @Suppress("SpellCheckingInspection") 85 | @Serializable 86 | data class ExternalIds( 87 | val isrc: String 88 | ) 89 | 90 | @Serializable 91 | data class ExternalUrls( 92 | val spotify: String 93 | ) 94 | 95 | @Serializable 96 | data class Image( 97 | val height: Int, 98 | val url: String, 99 | val width: Int 100 | ) 101 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/spotify/SpotifySyncedLyricsApi.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model.lyrics_providers.spotify 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class SyncedLinesResponse( 7 | val title: String, 8 | val artist: String, 9 | val cover: String, 10 | val lyrics: String 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/spotify/WebPlayerToken.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.domain.model.lyrics_providers.spotify 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class ServerTimeResponse( 7 | val serverTime: Long, 8 | ) 9 | 10 | @Serializable 11 | data class WebPlayerTokenResponse( 12 | val clientId: String, 13 | val accessToken: String, 14 | val accessTokenExpirationTimestampMs: Long, 15 | val isAnonymous: Boolean, 16 | ) 17 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/services/NotificationListener.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.services 2 | 3 | import android.service.notification.NotificationListenerService 4 | 5 | // Required for msm.getActiveSessions() 6 | class NotificationListener : NotificationListenerService() -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/Navigator.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui 2 | 3 | import androidx.compose.animation.ExperimentalSharedTransitionApi 4 | import androidx.compose.animation.SharedTransitionLayout 5 | import androidx.compose.runtime.Composable 6 | import androidx.lifecycle.viewmodel.compose.viewModel 7 | import androidx.navigation.NavHostController 8 | import androidx.navigation.compose.NavHost 9 | import androidx.navigation.toRoute 10 | import kotlinx.serialization.Serializable 11 | import pl.lambada.songsync.data.UserSettingsController 12 | import pl.lambada.songsync.data.remote.lyrics_providers.LyricsProviderService 13 | import pl.lambada.songsync.ui.common.animatedComposable 14 | import pl.lambada.songsync.ui.screens.home.HomeScreen 15 | import pl.lambada.songsync.ui.screens.home.HomeViewModel 16 | import pl.lambada.songsync.ui.screens.init.InitScreen 17 | import pl.lambada.songsync.ui.screens.init.InitScreenViewModel 18 | import pl.lambada.songsync.ui.screens.lyricsFetch.LyricsFetchScreen 19 | import pl.lambada.songsync.ui.screens.lyricsFetch.LyricsFetchViewModel 20 | import pl.lambada.songsync.ui.screens.settings.SettingsScreen 21 | import pl.lambada.songsync.ui.screens.settings.SettingsViewModel 22 | 23 | /** 24 | * Composable function for handling navigation within the app. 25 | * 26 | * @param navController The navigation controller. 27 | */ 28 | @OptIn(ExperimentalSharedTransitionApi::class) 29 | @Composable 30 | fun Navigator( 31 | navController: NavHostController, 32 | userSettingsController: UserSettingsController, 33 | lyricsProviderService: LyricsProviderService 34 | ) { 35 | SharedTransitionLayout { 36 | NavHost( 37 | navController = navController, 38 | startDestination = if (userSettingsController.passedInit) ScreenHome else InitScreen, 39 | ) { 40 | animatedComposable { 41 | InitScreen( 42 | navController = navController, 43 | viewModel = viewModel { 44 | InitScreenViewModel(userSettingsController) 45 | }, 46 | ) 47 | } 48 | animatedComposable { 49 | HomeScreen( 50 | navController = navController, 51 | viewModel = viewModel { 52 | HomeViewModel(userSettingsController, lyricsProviderService) 53 | }, 54 | sharedTransitionScope = this@SharedTransitionLayout, 55 | animatedVisibilityScope = this, 56 | ) 57 | } 58 | 59 | animatedComposable() { 60 | val args = it.toRoute() 61 | 62 | LyricsFetchScreen( 63 | viewModel = viewModel { 64 | LyricsFetchViewModel( 65 | args.source(), 66 | userSettingsController, 67 | lyricsProviderService 68 | ) 69 | }, 70 | navController = navController, 71 | animatedVisibilityScope = this, 72 | ) 73 | } 74 | animatedComposable { 75 | SettingsScreen( 76 | viewModel = viewModel { SettingsViewModel() }, 77 | userSettingsController, 78 | navController = navController 79 | ) 80 | } 81 | } 82 | } 83 | } 84 | 85 | @Serializable 86 | object InitScreen 87 | 88 | @Serializable 89 | object ScreenHome 90 | 91 | @Serializable 92 | data class LyricsFetchScreen( 93 | private val songName: String? = null, 94 | private val artists: String? = null, 95 | private val coverUri: String? = null, 96 | private val filePath: String? = null, 97 | ) { 98 | fun source() = if (songName != null && artists != null && filePath != null) { 99 | LocalSong(songName, artists, coverUri, filePath) 100 | } else null 101 | } 102 | 103 | @Serializable 104 | data class LocalSong( 105 | val songName: String, 106 | val artists: String, 107 | val coverUri: String?, 108 | val filePath: String, 109 | ) 110 | 111 | @Serializable 112 | object ScreenSettings -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/common/ComposableAnimations.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.common 2 | 3 | import androidx.compose.animation.ContentTransform 4 | import androidx.compose.animation.SizeTransform 5 | import pl.lambada.songsync.util.ui.materialSharedAxisXIn 6 | import pl.lambada.songsync.util.ui.materialSharedAxisXOut 7 | import pl.lambada.songsync.util.ui.materialSharedAxisYIn 8 | import pl.lambada.songsync.util.ui.materialSharedAxisYOut 9 | 10 | val AnimatedTextContentTransformation = ContentTransform( 11 | materialSharedAxisXIn(initialOffsetX = { it / 10 }), 12 | materialSharedAxisXOut(targetOffsetX = { -it / 10 }), 13 | sizeTransform = SizeTransform(clip = false) 14 | ) 15 | 16 | val AnimatedCardContentTransformation = ContentTransform( 17 | materialSharedAxisYIn(initialOffsetY = { it / 10 }), 18 | materialSharedAxisYOut(targetOffsetY = { -it / 10 }), 19 | sizeTransform = SizeTransform(clip = false) 20 | ) -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/components/SettingsHeadLabel.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.components 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.unit.dp 9 | 10 | @Composable 11 | fun SettingsHeadLabel( 12 | label: String, 13 | ) { 14 | Text( 15 | text = label, 16 | style = MaterialTheme.typography.labelMedium, 17 | modifier = Modifier.padding( 18 | start = 22.dp, 19 | top = 22.dp, 20 | end = 22.dp, 21 | bottom = 0.dp 22 | ), 23 | color = MaterialTheme.colorScheme.primary 24 | ) 25 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/components/SwitchItem.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.width 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Switch 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.CombinedModifier 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.text.font.FontStyle 18 | import androidx.compose.ui.unit.dp 19 | import androidx.compose.ui.unit.sp 20 | 21 | @Composable 22 | fun SwitchItem( 23 | label: String, 24 | selected: Boolean, 25 | modifier: Modifier = Modifier, 26 | innerPaddingValues: PaddingValues = PaddingValues( 27 | horizontal = 22.dp, 28 | vertical = 16.dp 29 | ), 30 | description: String = "", 31 | onClick: () -> Unit, 32 | ) { 33 | Row( 34 | modifier = CombinedModifier( 35 | inner = Modifier 36 | .clickable { onClick() } 37 | .padding(innerPaddingValues), 38 | outer = modifier 39 | ), 40 | verticalAlignment = Alignment.CenterVertically 41 | ) { 42 | Column( 43 | modifier = Modifier.weight(1f), 44 | ) { 45 | Text( 46 | text = label, 47 | ) 48 | if (description.isNotEmpty() == true) { 49 | Text( 50 | text = description, 51 | color = MaterialTheme.colorScheme.onSurfaceVariant, 52 | fontSize = 12.sp, 53 | lineHeight = 16.sp, 54 | ) 55 | } 56 | } 57 | Spacer(modifier = Modifier.width(12.dp)) 58 | Switch( 59 | checked = selected, 60 | onCheckedChange = { onClick() } 61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/components/TextField.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.components 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.foundation.text.KeyboardOptions 5 | import androidx.compose.material3.OutlinedTextField 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.text.input.ImeAction 10 | import androidx.compose.ui.unit.dp 11 | 12 | @Composable 13 | fun CommonTextField( 14 | modifier: Modifier = Modifier, 15 | value: String = "", 16 | onValueChange: (String) -> Unit = {}, 17 | label: String = "", 18 | singleLine: Boolean = true, 19 | imeAction: ImeAction = ImeAction.Done, 20 | readOnly: Boolean = false, 21 | ) { 22 | OutlinedTextField( 23 | value = value, 24 | onValueChange = onValueChange, 25 | label = { Text(text = label) }, 26 | singleLine = singleLine, 27 | shape = RoundedCornerShape(10.dp), 28 | keyboardOptions = KeyboardOptions(imeAction = imeAction), 29 | readOnly = readOnly, 30 | modifier = modifier, 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/components/dialogs/NoInternetDialog.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.components.dialogs 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.Button 6 | import androidx.compose.material3.OutlinedButton 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.res.stringResource 10 | import pl.lambada.songsync.R 11 | 12 | @Composable 13 | fun NoInternetDialog(onConfirm: () -> Unit, onIgnore: () -> Unit) { 14 | AlertDialog( 15 | onDismissRequest = { /* don't dismiss */ }, 16 | confirmButton = { 17 | Button(onConfirm) { 18 | Text(stringResource(R.string.close_app)) 19 | } 20 | }, 21 | dismissButton = { 22 | OutlinedButton(onIgnore) { 23 | Text(stringResource(R.string.ignore)) 24 | } 25 | }, 26 | title = { Text(stringResource(R.string.no_internet_connection)) }, text = { 27 | Column { 28 | Text(stringResource(R.string.you_need_internet_connection)) 29 | Text(stringResource(R.string.check_your_connection)) 30 | Text(stringResource(R.string.if_connected_spotify_down)) 31 | } 32 | } 33 | ) 34 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/components/dropdown/M3ElevationTokens.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.components.dropdown 2 | 3 | /** 4 | * The tonal elevation tokens. 5 | * 6 | * @see androidx.compose.material3.tokens.ElevationTokens 7 | */ 8 | internal object ElevationTokens { 9 | const val Level0 = 0 10 | const val Level1 = 1 11 | const val Level2 = 3 12 | const val Level3 = 6 13 | const val Level4 = 8 14 | const val Level5 = 12 15 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/home/components/BatchDownloadLyrics.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.home.components 2 | 3 | import android.annotation.SuppressLint 4 | import android.os.Build 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.mutableIntStateOf 8 | import androidx.compose.runtime.mutableStateOf 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.runtime.saveable.rememberSaveable 11 | import androidx.compose.runtime.setValue 12 | import androidx.compose.ui.platform.LocalContext 13 | import pl.lambada.songsync.ui.screens.home.HomeViewModel 14 | import pl.lambada.songsync.ui.screens.home.components.batchDownload.BatchDownloadWarningDialog 15 | import pl.lambada.songsync.ui.screens.home.components.batchDownload.DownloadCompleteDialog 16 | import pl.lambada.songsync.ui.screens.home.components.batchDownload.DownloadProgressDialog 17 | import pl.lambada.songsync.ui.screens.home.components.batchDownload.LegacyPromptDialog 18 | import pl.lambada.songsync.ui.screens.home.components.batchDownload.RateLimitedDialog 19 | import kotlin.math.roundToInt 20 | 21 | @SuppressLint("StringFormatMatches") 22 | @Composable 23 | fun BatchDownloadLyrics(viewModel: HomeViewModel, onDone: () -> Unit) { 24 | val songs = viewModel.songsToBatchDownload 25 | var uiState by rememberSaveable { mutableStateOf(UiState.Warning) } 26 | var successCount by rememberSaveable { mutableIntStateOf(0) } 27 | var noLyricsCount by rememberSaveable { mutableIntStateOf(0) } 28 | var failedCount by rememberSaveable { mutableIntStateOf(0) } 29 | val count = successCount + failedCount + noLyricsCount 30 | val total = songs.size 31 | val context = LocalContext.current 32 | val startBatchDownload = remember { 33 | { 34 | viewModel.batchDownloadLyrics( 35 | context, 36 | onProgressUpdate = { newSuccessCount, newNoLyricsCount, newFailedCount -> 37 | successCount = newSuccessCount 38 | noLyricsCount = newNoLyricsCount 39 | failedCount = newFailedCount 40 | }, 41 | onDownloadComplete = { uiState = UiState.Done }, 42 | onRateLimitReached = { uiState = UiState.RateLimited } 43 | ) 44 | } 45 | } 46 | 47 | when (uiState) { 48 | UiState.Cancelled -> onDone() 49 | UiState.Warning -> BatchDownloadWarningDialog( 50 | songsCount = songs.size, 51 | onConfirm = { 52 | uiState = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { 53 | UiState.LegacyPrompt 54 | } else { 55 | startBatchDownload() 56 | UiState.Pending 57 | } 58 | }, 59 | onDismiss = { uiState = UiState.Cancelled }, 60 | embedLyrics = viewModel.userSettingsController.embedLyricsIntoFiles, 61 | onEmbedLyricsChangeRequest = viewModel.userSettingsController::updateEmbedLyrics, 62 | ) 63 | 64 | UiState.LegacyPrompt -> LegacyPromptDialog( 65 | onConfirm = { 66 | uiState = UiState.Pending 67 | startBatchDownload() 68 | }, 69 | onDismiss = { uiState = UiState.Cancelled } 70 | ) 71 | 72 | UiState.Pending -> { 73 | val percentage = 74 | if (total != 0) (count.toFloat() / total.toFloat() * 100).roundToInt() else 0 75 | 76 | DownloadProgressDialog( 77 | currentSongTitle = songs.getOrNull(count % total)?.title, 78 | count = count, 79 | total = total, 80 | percentage = percentage, 81 | successCount = successCount, 82 | noLyricsCount = noLyricsCount, 83 | failedCount = failedCount, 84 | onCancel = { uiState = UiState.Cancelled }, 85 | disableMarquee = viewModel.userSettingsController.disableMarquee 86 | ) 87 | } 88 | 89 | UiState.Done -> DownloadCompleteDialog( 90 | successCount = successCount, 91 | noLyricsCount = noLyricsCount, 92 | failedCount = failedCount, 93 | onDismiss = { uiState = UiState.Cancelled } 94 | ) 95 | 96 | UiState.RateLimited -> RateLimitedDialog(onDismiss = { uiState = UiState.Cancelled }) 97 | } 98 | } 99 | 100 | enum class UiState { 101 | Warning, LegacyPrompt, Pending, Done, RateLimited, Cancelled 102 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/home/components/FilterAndSongCount.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.home.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.automirrored.filled.Sort 8 | import androidx.compose.material.icons.filled.Search 9 | import androidx.compose.material.icons.filled.Sort 10 | import androidx.compose.material.icons.outlined.FilterAlt 11 | import androidx.compose.material3.ExperimentalMaterial3Api 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.IconButton 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.res.pluralStringResource 19 | import androidx.compose.ui.res.stringResource 20 | import pl.lambada.songsync.R 21 | 22 | @OptIn(ExperimentalMaterial3Api::class) 23 | @Composable 24 | fun FilterAndSongCount( 25 | displaySongsCount: Int, 26 | onFilterClick: () -> Unit, 27 | onSortClick: () -> Unit, 28 | onSearchClick: () -> Unit 29 | ) { 30 | Row( 31 | verticalAlignment = Alignment.CenterVertically, 32 | horizontalArrangement = Arrangement.Center 33 | ) { 34 | Text(text = pluralStringResource(R.plurals.songs_count, displaySongsCount, displaySongsCount)) 35 | Spacer(modifier = Modifier.weight(1f)) 36 | IconButton(onClick = onSortClick) { 37 | Icon( 38 | Icons.AutoMirrored.Filled.Sort, 39 | contentDescription = stringResource(R.string.sort), 40 | ) 41 | } 42 | 43 | IconButton(onClick = onFilterClick) { 44 | Icon( 45 | Icons.Outlined.FilterAlt, 46 | contentDescription = stringResource(R.string.search), 47 | ) 48 | } 49 | 50 | IconButton(onClick = onSearchClick) { 51 | Icon( 52 | Icons.Default.Search, 53 | contentDescription = stringResource(R.string.search), 54 | ) 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/home/components/HomeSearchBar.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.home.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 5 | import androidx.compose.foundation.layout.WindowInsets 6 | import androidx.compose.foundation.layout.isImeVisible 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.text.KeyboardActions 9 | import androidx.compose.foundation.text.KeyboardOptions 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.Clear 12 | import androidx.compose.material.icons.filled.Search 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.ShapeDefaults 16 | import androidx.compose.material3.Text 17 | import androidx.compose.material3.TextField 18 | import androidx.compose.material3.TextFieldDefaults 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.mutableStateOf 22 | import androidx.compose.runtime.remember 23 | import androidx.compose.runtime.setValue 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.focus.FocusRequester 26 | import androidx.compose.ui.focus.focusRequester 27 | import androidx.compose.ui.focus.onFocusChanged 28 | import androidx.compose.ui.graphics.Color 29 | import androidx.compose.ui.layout.onGloballyPositioned 30 | import androidx.compose.ui.platform.LocalFocusManager 31 | import androidx.compose.ui.res.stringResource 32 | import androidx.compose.ui.text.input.ImeAction 33 | import androidx.compose.ui.unit.dp 34 | import pl.lambada.songsync.R 35 | import pl.lambada.songsync.util.ext.BackPressHandler 36 | 37 | @Composable 38 | fun HomeSearchBar( 39 | query: String, 40 | onQueryChange: (String) -> Unit, 41 | showSearch: Boolean, 42 | onShowSearchChange: (Boolean) -> Unit, 43 | showingSearch: Boolean, 44 | onShowingSearchChange: (Boolean) -> Unit, 45 | ) { 46 | val focusRequester = remember { FocusRequester() } 47 | var willShowIme by remember { mutableStateOf(false) } 48 | 49 | @OptIn(ExperimentalLayoutApi::class) 50 | val showingIme = WindowInsets.isImeVisible 51 | 52 | if (!showingSearch && showSearch) { 53 | onShowingSearchChange(true) 54 | } 55 | 56 | if (!showSearch && !willShowIme && showingSearch && !showingIme && query.isEmpty()) { 57 | onShowingSearchChange(false) 58 | } 59 | 60 | if (willShowIme && showingIme) { 61 | willShowIme = false 62 | } 63 | val focusManager = LocalFocusManager.current 64 | 65 | BackPressHandler( 66 | enabled = showingSearch, 67 | onBackPressed = { 68 | onQueryChange("") 69 | onShowSearchChange(false) 70 | onShowingSearchChange(false) 71 | } 72 | ) 73 | 74 | TextField( 75 | query, 76 | onValueChange = onQueryChange, 77 | leadingIcon = { 78 | Icon( 79 | Icons.Filled.Search, 80 | contentDescription = stringResource(id = R.string.search), 81 | modifier = Modifier.clickable { 82 | onShowSearchChange(false) 83 | onShowingSearchChange(false) 84 | } 85 | ) 86 | }, 87 | trailingIcon = { 88 | Icon( 89 | imageVector = Icons.Default.Clear, 90 | contentDescription = stringResource(id = R.string.clear), 91 | modifier = Modifier.clickable { 92 | onQueryChange("") 93 | onShowSearchChange(false) 94 | onShowingSearchChange(false) 95 | } 96 | ) 97 | }, 98 | placeholder = { Text(stringResource(id = R.string.search)) }, 99 | shape = ShapeDefaults.ExtraLarge, 100 | colors = TextFieldDefaults.colors( 101 | focusedLabelColor = MaterialTheme.colorScheme.onSurface, 102 | focusedIndicatorColor = Color.Transparent, 103 | unfocusedIndicatorColor = Color.Transparent, 104 | disabledIndicatorColor = Color.Transparent 105 | ), 106 | modifier = Modifier 107 | .padding(end = 18.dp) 108 | .focusRequester(focusRequester) 109 | .onFocusChanged { 110 | if (it.isFocused && !showingIme) willShowIme = true 111 | } 112 | .onGloballyPositioned { 113 | if (showSearch && !showingIme) { 114 | focusRequester.requestFocus() 115 | onShowSearchChange(false) 116 | } 117 | }, 118 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), 119 | keyboardActions = KeyboardActions( 120 | onSearch = { focusManager.clearFocus() } 121 | ) 122 | ) 123 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/home/components/HomeSearchThing.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.home.components 2 | 3 | import androidx.compose.animation.AnimatedContent 4 | import androidx.compose.animation.SizeTransform 5 | import androidx.compose.animation.fadeIn 6 | import androidx.compose.animation.fadeOut 7 | import androidx.compose.animation.slideInVertically 8 | import androidx.compose.animation.slideOutVertically 9 | import androidx.compose.animation.togetherWith 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.height 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.unit.dp 15 | 16 | @Composable 17 | fun HomeSearchThing( 18 | showingSearch: Boolean, 19 | searchBar: @Composable () -> Unit, 20 | filterBar: @Composable () -> Unit 21 | ) { 22 | AnimatedContent( 23 | targetState = showingSearch, 24 | transitionSpec = { 25 | if (targetState) { 26 | (slideInVertically { height -> height } + fadeIn()).togetherWith( 27 | slideOutVertically { height -> -height } + fadeOut() 28 | ) 29 | } else { 30 | (slideInVertically { height -> -height } + fadeIn()).togetherWith( 31 | slideOutVertically { height -> height } + fadeOut() 32 | ) 33 | }.using( 34 | SizeTransform() 35 | ) 36 | }, 37 | label = "", 38 | modifier = Modifier 39 | .fillMaxWidth() 40 | .height(55.dp) 41 | ) { showing -> 42 | if (showing) searchBar() else filterBar() 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/home/components/batchDownload/BatchDownloadWarningDialog.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.home.components.batchDownload 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.shape.RoundedCornerShape 9 | import androidx.compose.material3.AlertDialog 10 | import androidx.compose.material3.Button 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.OutlinedButton 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.clip 17 | import androidx.compose.ui.res.pluralStringResource 18 | import androidx.compose.ui.res.stringResource 19 | import androidx.compose.ui.unit.dp 20 | import pl.lambada.songsync.R 21 | import pl.lambada.songsync.ui.components.SwitchItem 22 | 23 | @Composable 24 | fun BatchDownloadWarningDialog( 25 | songsCount: Int, 26 | onConfirm: () -> Unit, 27 | onDismiss: () -> Unit, 28 | embedLyrics: Boolean, 29 | onEmbedLyricsChangeRequest: (Boolean) -> Unit, 30 | ) { 31 | AlertDialog( 32 | title = { 33 | Text(text = stringResource(id = R.string.batch_download_lyrics)) 34 | }, 35 | text = { 36 | Column { 37 | Text( 38 | text = pluralStringResource( 39 | R.plurals.this_will_download_lyrics_for_all_songs, 40 | songsCount, 41 | songsCount 42 | ) 43 | ) 44 | Spacer(modifier = Modifier.height(16.dp)) 45 | SwitchItem( 46 | label = stringResource(R.string.embed_lyrics_in_file), 47 | selected = embedLyrics, 48 | modifier = Modifier 49 | .clip(RoundedCornerShape(50f)) 50 | .background(MaterialTheme.colorScheme.secondaryContainer), 51 | innerPaddingValues = PaddingValues( 52 | top = 8.dp, 53 | start = 18.dp, 54 | end = 10.dp, 55 | bottom = 8.dp 56 | ) 57 | ) { onEmbedLyricsChangeRequest(!embedLyrics) } 58 | } 59 | }, 60 | onDismissRequest = onDismiss, 61 | confirmButton = { 62 | Button(onClick = onConfirm) { 63 | Text(text = stringResource(R.string.yes)) 64 | } 65 | }, 66 | dismissButton = { 67 | OutlinedButton(onClick = onDismiss) { 68 | Text(text = stringResource(R.string.no)) 69 | } 70 | } 71 | ) 72 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/home/components/batchDownload/DownloadCompleteDialog.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.home.components.batchDownload 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.Button 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.res.stringResource 9 | import pl.lambada.songsync.R 10 | 11 | @Composable 12 | fun DownloadCompleteDialog( 13 | successCount: Int, 14 | noLyricsCount: Int, 15 | failedCount: Int, 16 | onDismiss: () -> Unit 17 | ) { 18 | AlertDialog( 19 | title = { 20 | Text(text = stringResource(id = R.string.batch_download_lyrics)) 21 | }, 22 | text = { 23 | Column { 24 | Text(text = stringResource(R.string.download_complete)) 25 | Text(text = stringResource(R.string.success, successCount)) 26 | Text(text = stringResource(R.string.no_lyrics, noLyricsCount)) 27 | Text(text = stringResource(R.string.failed, failedCount)) 28 | } 29 | }, 30 | onDismissRequest = onDismiss, 31 | confirmButton = { 32 | Button(onClick = onDismiss) { 33 | Text(text = stringResource(id = R.string.ok)) 34 | } 35 | } 36 | ) 37 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/home/components/batchDownload/DownloadProgressDialog.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.home.components.batchDownload 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.OutlinedButton 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.res.stringResource 9 | import pl.lambada.songsync.R 10 | import pl.lambada.songsync.ui.components.AnimatedText 11 | 12 | @Composable 13 | fun DownloadProgressDialog( 14 | currentSongTitle: String?, 15 | count: Int, 16 | total: Int, 17 | percentage: Int, 18 | successCount: Int, 19 | noLyricsCount: Int, 20 | failedCount: Int, 21 | onCancel: () -> Unit, 22 | disableMarquee: Boolean, 23 | ) { 24 | AlertDialog( 25 | title = { 26 | Text(text = stringResource(id = R.string.batch_download_lyrics)) 27 | }, 28 | text = { 29 | Column { 30 | Text(text = stringResource(R.string.downloading_lyrics)) 31 | AnimatedText( 32 | animate = !disableMarquee, 33 | text = stringResource( 34 | R.string.song, 35 | currentSongTitle ?: stringResource(id = R.string.unknown) 36 | ) 37 | ) 38 | Text(text = stringResource(R.string.progress, count, total, percentage)) 39 | Text( 40 | text = stringResource( 41 | R.string.success_failed, successCount, noLyricsCount, failedCount 42 | ) 43 | ) 44 | Text(text = stringResource(R.string.please_do_not_close_the_app_this_may_take_a_while)) 45 | } 46 | }, 47 | onDismissRequest = { /* Prevent accidental dismiss */ }, 48 | confirmButton = { /* Empty but required */ }, 49 | dismissButton = { 50 | OutlinedButton(onClick = onCancel) { 51 | Text(text = stringResource(R.string.cancel)) 52 | } 53 | } 54 | ) 55 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/home/components/batchDownload/LegacyPromptDialog.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.home.components.batchDownload 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.Button 6 | import androidx.compose.material3.OutlinedButton 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.res.stringResource 10 | import pl.lambada.songsync.R 11 | 12 | @Composable 13 | fun LegacyPromptDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { 14 | AlertDialog( 15 | title = { 16 | Text(text = stringResource(id = R.string.batch_download_lyrics)) 17 | }, 18 | text = { 19 | Column { 20 | Text(text = stringResource(R.string.set_sd_path_warn)) 21 | } 22 | }, 23 | onDismissRequest = onDismiss, 24 | confirmButton = { 25 | Button(onClick = onConfirm) { 26 | Text(text = stringResource(R.string.ok)) 27 | } 28 | }, 29 | dismissButton = { 30 | OutlinedButton(onClick = onDismiss) { 31 | Text(text = stringResource(R.string.cancel)) 32 | } 33 | }, 34 | ) 35 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/home/components/batchDownload/RateLimitedDialog.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.home.components.batchDownload 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.Button 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.res.stringResource 9 | import pl.lambada.songsync.R 10 | 11 | @Composable 12 | fun RateLimitedDialog(onDismiss: () -> Unit) { 13 | AlertDialog( 14 | title = { 15 | Text(text = stringResource(id = R.string.batch_download_lyrics)) 16 | }, 17 | text = { 18 | Column { 19 | Text(text = stringResource(R.string.spotify_api_rate_limit_reached)) 20 | Text(text = stringResource(R.string.please_try_again_later)) 21 | Text(text = stringResource(R.string.change_api_strategy)) 22 | } 23 | }, 24 | onDismissRequest = onDismiss, 25 | confirmButton = { 26 | Button(onClick = onDismiss) { 27 | Text(text = stringResource(id = R.string.ok)) 28 | } 29 | } 30 | ) 31 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/init/InitScreenViewModel.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.init 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import android.os.Environment 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.runtime.setValue 9 | import androidx.core.app.NotificationManagerCompat 10 | import androidx.core.content.ContextCompat 11 | import androidx.lifecycle.ViewModel 12 | import pl.lambada.songsync.data.UserSettingsController 13 | 14 | class InitScreenViewModel( 15 | val userSettingsController: UserSettingsController, 16 | ): ViewModel() { 17 | var allFilesClicked by mutableStateOf(false) 18 | var notificationClicked by mutableStateOf(false) 19 | var notificationAccessClicked by mutableStateOf(false) 20 | 21 | var allFilesPermissionGranted by mutableStateOf(false) 22 | var notificationPermissionGranted by mutableStateOf(false) 23 | var notificationAccessPermissionGranted by mutableStateOf(false) 24 | 25 | fun onLoad(context: Context) { 26 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 27 | allFilesPermissionGranted = Environment.isExternalStorageManager() 28 | } else { 29 | allFilesPermissionGranted = ContextCompat.checkSelfPermission( 30 | context, 31 | android.Manifest.permission.WRITE_EXTERNAL_STORAGE 32 | ) == android.content.pm.PackageManager.PERMISSION_GRANTED 33 | && ContextCompat.checkSelfPermission( 34 | context, 35 | android.Manifest.permission.READ_EXTERNAL_STORAGE 36 | ) == android.content.pm.PackageManager.PERMISSION_GRANTED 37 | } 38 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 39 | notificationPermissionGranted = ContextCompat.checkSelfPermission( 40 | context, 41 | android.Manifest.permission.POST_NOTIFICATIONS 42 | ) == android.content.pm.PackageManager.PERMISSION_GRANTED 43 | } 44 | notificationAccessPermissionGranted = NotificationManagerCompat 45 | .getEnabledListenerPackages(context).contains(context.packageName) 46 | } 47 | 48 | fun onProceed() { 49 | userSettingsController.updatePassedInit(true) 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/init/components/InitTopBar.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.init.components 2 | 3 | import androidx.compose.material3.ExperimentalMaterial3Api 4 | import androidx.compose.material3.LargeTopAppBar 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.res.stringResource 8 | import pl.lambada.songsync.R 9 | 10 | @OptIn(ExperimentalMaterial3Api::class) 11 | @Composable 12 | fun InitScreenTopBar() { 13 | LargeTopAppBar( 14 | title = { 15 | Text( 16 | text = stringResource(id = R.string.init_screen_title) 17 | ) 18 | } 19 | ) 20 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/init/components/PermissionItem.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.init.components 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.Spacer 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.width 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.automirrored.filled.Launch 13 | import androidx.compose.material.icons.filled.CheckCircle 14 | import androidx.compose.material.icons.filled.Launch 15 | import androidx.compose.material.icons.filled.RemoveCircleOutline 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.Text 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.res.stringResource 23 | import androidx.compose.ui.unit.dp 24 | import androidx.compose.ui.unit.sp 25 | 26 | @Composable 27 | fun PermissionItem( 28 | @StringRes title: Int, 29 | @StringRes description: Int, 30 | onClick: () -> Unit, 31 | granted: Boolean, 32 | innerPaddingValues: PaddingValues = PaddingValues( 33 | horizontal = 8.dp, 34 | vertical = 16.dp 35 | ), 36 | ) { 37 | Row( 38 | modifier = Modifier 39 | .clickable(!granted) { onClick() } 40 | .padding(innerPaddingValues), 41 | verticalAlignment = Alignment.CenterVertically 42 | ) { 43 | Column( 44 | modifier = Modifier.weight(1f), 45 | ) { 46 | Text( 47 | text = stringResource(id = title), 48 | ) 49 | Text( 50 | text = stringResource(id = description), 51 | color = MaterialTheme.colorScheme.onSurfaceVariant, 52 | fontSize = 12.sp, 53 | lineHeight = 16.sp, 54 | ) 55 | } 56 | Spacer(modifier = Modifier.width(12.dp)) 57 | if (granted) { 58 | Icon( 59 | imageVector = Icons.Default.CheckCircle, 60 | contentDescription = null, 61 | tint = MaterialTheme.colorScheme.tertiary 62 | ) 63 | } else { 64 | Icon( 65 | imageVector = Icons.AutoMirrored.Default.Launch, 66 | contentDescription = null, 67 | ) 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/init/components/permissions/AllFilesAccess.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.init.components.permissions 2 | 3 | import android.app.ActivityManager 4 | import android.content.Intent 5 | import android.os.Build 6 | import android.os.Environment 7 | import android.provider.Settings 8 | import androidx.activity.compose.rememberLauncherForActivityResult 9 | import androidx.activity.result.ActivityResultLauncher 10 | import androidx.activity.result.contract.ActivityResultContracts 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.LaunchedEffect 13 | import androidx.compose.ui.platform.LocalContext 14 | import com.google.accompanist.permissions.ExperimentalPermissionsApi 15 | import com.google.accompanist.permissions.isGranted 16 | import com.google.accompanist.permissions.rememberMultiplePermissionsState 17 | 18 | @OptIn(ExperimentalPermissionsApi::class) 19 | @Composable 20 | fun AllFilesAccess( 21 | onGranted: () -> Unit, 22 | onDismiss: () -> Unit 23 | ) { 24 | val context = LocalContext.current 25 | var storageManager: ActivityResultLauncher? = null 26 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 27 | storageManager = 28 | rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { 29 | if (Environment.isExternalStorageManager()) { 30 | onGranted() 31 | } 32 | onDismiss() 33 | } 34 | } 35 | val storagePermissionState = rememberMultiplePermissionsState( 36 | listOf( 37 | android.Manifest.permission.READ_EXTERNAL_STORAGE, 38 | android.Manifest.permission.WRITE_EXTERNAL_STORAGE 39 | ), 40 | onPermissionsResult = { 41 | if ( 42 | it[android.Manifest.permission.READ_EXTERNAL_STORAGE]!! && 43 | it[android.Manifest.permission.WRITE_EXTERNAL_STORAGE]!! 44 | ) { 45 | onGranted() 46 | } 47 | onDismiss() 48 | } 49 | ) 50 | LaunchedEffect(Unit) { 51 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 52 | val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) 53 | intent.addCategory("android.intent.category.DEFAULT") 54 | intent.data = android.net.Uri.parse( 55 | String.format( 56 | "package:%s", 57 | context.applicationContext.packageName 58 | ) 59 | ) 60 | storageManager!!.launch(intent) 61 | } else { 62 | storagePermissionState.launchMultiplePermissionRequest() 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/init/components/permissions/NotificationPermission.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.init.components.permissions 2 | 3 | import android.content.ComponentName 4 | import android.content.Intent 5 | import android.os.Build 6 | import android.provider.Settings 7 | import androidx.activity.compose.rememberLauncherForActivityResult 8 | import androidx.activity.result.ActivityResultLauncher 9 | import androidx.activity.result.contract.ActivityResultContracts 10 | import androidx.annotation.RequiresApi 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.LaunchedEffect 13 | import androidx.compose.ui.platform.LocalContext 14 | import androidx.core.app.NotificationManagerCompat 15 | import pl.lambada.songsync.services.NotificationListener 16 | 17 | @RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) 18 | @Composable 19 | fun NotificationPermission( 20 | onGranted: () -> Unit, 21 | onDismiss: () -> Unit 22 | ) { 23 | val context = LocalContext.current 24 | val notificationManager = rememberLauncherForActivityResult( 25 | ActivityResultContracts.StartActivityForResult() 26 | ) { result -> 27 | if ( 28 | NotificationManagerCompat.getEnabledListenerPackages(context) 29 | .contains(context.packageName) 30 | ) { 31 | onGranted() 32 | } 33 | onDismiss() 34 | } 35 | LaunchedEffect(Unit) { 36 | val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) 37 | notificationManager.launch(intent) 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/init/components/permissions/PostNotifications.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.init.components.permissions 2 | 3 | import android.os.Build 4 | import androidx.annotation.RequiresApi 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.LaunchedEffect 7 | import com.google.accompanist.permissions.ExperimentalPermissionsApi 8 | import com.google.accompanist.permissions.rememberPermissionState 9 | 10 | @RequiresApi(Build.VERSION_CODES.TIRAMISU) 11 | @OptIn(ExperimentalPermissionsApi::class) 12 | @Composable 13 | fun PostNotifications( 14 | onGranted: () -> Unit, 15 | onDismiss: () -> Unit 16 | ) { 17 | var notificationPermission = rememberPermissionState( 18 | permission = android.Manifest.permission.POST_NOTIFICATIONS, 19 | onPermissionResult = { 20 | if (it) { 21 | onGranted() 22 | } 23 | onDismiss() 24 | } 25 | ) 26 | LaunchedEffect(Unit) { 27 | notificationPermission.launchPermissionRequest() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/components/CloudProviderTitle.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.lyricsFetch.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.Cloud 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.draw.clip 14 | import androidx.compose.ui.unit.dp 15 | import pl.lambada.songsync.util.Providers 16 | 17 | @Composable 18 | fun CloudProviderTitle( 19 | selectedProvider: Providers, 20 | onExpandProvidersRequest: () -> Unit, 21 | ) { 22 | Row( 23 | modifier = Modifier 24 | .clip(RoundedCornerShape(4.dp)) 25 | .clickable(onClick = onExpandProvidersRequest) 26 | .padding(horizontal = 4.dp) 27 | ) { 28 | Icon( 29 | imageVector = Icons.Filled.Cloud, 30 | contentDescription = null, 31 | Modifier.padding(end = 5.dp) 32 | ) 33 | Text(text = selectedProvider.displayName) 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/components/FailedDialogue.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.lyricsFetch.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.Button 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.res.stringResource 9 | import pl.lambada.songsync.R 10 | import pl.lambada.songsync.util.EmptyQueryException 11 | import pl.lambada.songsync.util.NoTrackFoundException 12 | import java.io.FileNotFoundException 13 | 14 | /** 15 | * Composable function to display a dialog for failed operations. 16 | * 17 | * @param onDismissRequest Callback to be invoked when the dialog is dismissed. 18 | * @param onOkRequest Callback to be invoked when the OK button is pressed. 19 | * @param exception The exception that caused the failure. 20 | */ 21 | @Composable 22 | fun FailedDialogue( 23 | onDismissRequest: () -> Unit, 24 | onOkRequest: () -> Unit, 25 | exception: Exception 26 | ) { 27 | AlertDialog( 28 | onDismissRequest = onDismissRequest, 29 | confirmButton = { Button(onClick = onOkRequest) { Text(stringResource(R.string.ok)) } }, 30 | title = { Text(text = stringResource(id = R.string.error)) }, 31 | text = { 32 | when (exception) { 33 | is NoTrackFoundException -> Text(stringResource(R.string.no_results)) 34 | is EmptyQueryException -> Text(stringResource(R.string.invalid_query)) 35 | else -> Text(exception.toString()) 36 | } 37 | } 38 | ) 39 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/components/LocalSongContent.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.lyricsFetch.components 2 | 3 | import androidx.compose.animation.AnimatedVisibilityScope 4 | import androidx.compose.animation.ExperimentalSharedTransitionApi 5 | import androidx.compose.animation.SharedTransitionScope 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.Downloading 12 | import androidx.compose.material.icons.filled.PlayCircleOutline 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.res.stringResource 18 | import androidx.compose.ui.unit.dp 19 | import pl.lambada.songsync.R 20 | import pl.lambada.songsync.ui.LocalSong 21 | import pl.lambada.songsync.ui.components.SongCard 22 | 23 | @OptIn(ExperimentalSharedTransitionApi::class) 24 | @Composable 25 | fun SharedTransitionScope.LocalSongContent( 26 | song: LocalSong, 27 | animatedVisibilityScope: AnimatedVisibilityScope, 28 | disableMarquee: Boolean 29 | ) { 30 | Row { 31 | if (song.filePath.isNotEmpty()) { 32 | Icon( 33 | imageVector = Icons.Filled.Downloading, 34 | contentDescription = null, 35 | Modifier.padding(end = 5.dp) 36 | ) 37 | Text(stringResource(R.string.local_song)) 38 | } else { 39 | Icon( 40 | imageVector = Icons.Filled.PlayCircleOutline, 41 | contentDescription = null, 42 | Modifier.padding(end = 5.dp) 43 | ) 44 | Text(stringResource(R.string.now_playing_song)) 45 | } 46 | } 47 | Spacer(modifier = Modifier.height(6.dp)) 48 | SongCard( 49 | filePath = song.filePath, 50 | songName = song.songName, 51 | artists = song.artists, 52 | coverUrl = song.coverUri, 53 | animatedVisibilityScope = animatedVisibilityScope, 54 | animateText = !disableMarquee, 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/components/NoConnectionDialogue.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.lyricsFetch.components 2 | 3 | import androidx.compose.material3.AlertDialog 4 | import androidx.compose.material3.Button 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.res.stringResource 8 | import pl.lambada.songsync.R 9 | 10 | @Composable 11 | fun NoConnectionDialogue( 12 | onDismissRequest: () -> Unit, 13 | onOkRequest: () -> Unit 14 | ) { 15 | AlertDialog( 16 | onDismissRequest = onDismissRequest, 17 | confirmButton = { Button(onOkRequest) { Text(stringResource(R.string.ok)) } }, 18 | title = { Text(text = stringResource(id = R.string.error)) }, 19 | text = { Text(stringResource(R.string.no_internet_server)) } 20 | ) 21 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/components/NotSubmittedContent.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.lyricsFetch.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.material3.Button 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.res.stringResource 13 | import androidx.compose.ui.text.input.ImeAction 14 | import androidx.compose.ui.unit.dp 15 | import pl.lambada.songsync.R 16 | import pl.lambada.songsync.ui.components.CommonTextField 17 | 18 | 19 | @Composable 20 | fun NotSubmittedContent( 21 | querySong: String, 22 | onQuerySongChange: (String) -> Unit, 23 | queryArtist: String, 24 | onQueryArtistChange: (String) -> Unit, 25 | onGetLyricsRequest: () -> Unit 26 | ) { 27 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 28 | Spacer(modifier = Modifier.height(16.dp)) 29 | CommonTextField( 30 | value = querySong, 31 | onValueChange = onQuerySongChange, 32 | label = stringResource(id = R.string.song_name_no_args), 33 | imeAction = ImeAction.Next, 34 | modifier = Modifier.fillMaxWidth() 35 | ) 36 | Spacer(modifier = Modifier.height(6.dp)) 37 | CommonTextField( 38 | value = queryArtist, 39 | onValueChange = onQueryArtistChange, 40 | label = stringResource(R.string.artist_name_no_args), 41 | modifier = Modifier.fillMaxWidth() 42 | ) 43 | Spacer(modifier = Modifier.height(8.dp)) 44 | Button(onClick = onGetLyricsRequest) { 45 | Text(text = stringResource(id = R.string.get_lyrics)) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/SettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.ViewModel 8 | import androidx.lifecycle.viewModelScope 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.launch 11 | import kotlinx.coroutines.withContext 12 | import pl.lambada.songsync.R 13 | import pl.lambada.songsync.data.remote.UpdateService 14 | import pl.lambada.songsync.data.remote.UpdateState 15 | import pl.lambada.songsync.util.showToast 16 | 17 | /** 18 | * ViewModel class for the main functionality of the app. 19 | */ 20 | class SettingsViewModel( 21 | private val updateService: UpdateService = UpdateService() 22 | ) : ViewModel() { 23 | var updateState by mutableStateOf(UpdateState.Idle) 24 | 25 | fun dismissUpdate() { updateState = UpdateState.Idle } 26 | 27 | fun checkForUpdates(context: Context) { 28 | viewModelScope.launch { 29 | withContext(Dispatchers.IO) { updateService.checkForUpdates(context) }.collect { 30 | updateState = it 31 | 32 | when (it) { 33 | UpdateState.Checking -> showToast( 34 | context, 35 | context.getString(R.string.checking_for_updates), 36 | long = false 37 | ) 38 | 39 | is UpdateState.Error -> showToast( 40 | context, 41 | context.getString(R.string.error_checking_for_updates), 42 | long = false 43 | ) 44 | 45 | UpdateState.UpToDate -> showToast( 46 | context, 47 | context.getString(R.string.up_to_date), 48 | long = false 49 | ) 50 | else -> { } 51 | } 52 | } 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/AppInfoSection.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.Button 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.unit.dp 13 | import pl.lambada.songsync.R 14 | 15 | @Composable 16 | fun AppInfoSection(version: String, onCheckForUpdates: () -> Unit) { 17 | Column( 18 | modifier = Modifier 19 | .padding(horizontal = 22.dp, vertical = 16.dp), 20 | ) { 21 | Text(stringResource(R.string.what_is_songsync)) 22 | Text(stringResource(R.string.extra_what_is_songsync)) 23 | Text("") 24 | Text(stringResource(R.string.app_version, version)) 25 | Row { 26 | Spacer(modifier = Modifier.weight(1f)) 27 | Button( 28 | modifier = Modifier.padding(top = 8.dp), 29 | onClick = onCheckForUpdates 30 | ) { 31 | Text(stringResource(R.string.check_for_updates)) 32 | } 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/ContributorsSection.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.platform.UriHandler 12 | import androidx.compose.ui.res.stringResource 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | 16 | @Composable 17 | fun ContributorsSection(uriHandler: UriHandler) { 18 | Column{ 19 | Contributor.entries.forEach { 20 | val additionalInfo = stringResource(id = it.contributionLevel.stringResource) 21 | Column( 22 | modifier = Modifier 23 | .fillMaxWidth() 24 | .clickable { it.github?.let { it1 -> uriHandler.openUri(it1) } } 25 | .padding(horizontal = 22.dp, vertical = 16.dp) 26 | ) { 27 | Text(text = it.devName) 28 | Text( 29 | text = additionalInfo, 30 | color = MaterialTheme.colorScheme.outline, 31 | fontSize = 12.sp 32 | ) 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/CreditsSection.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.width 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.automirrored.filled.OpenInNew 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.platform.UriHandler 19 | import androidx.compose.ui.res.stringResource 20 | import androidx.compose.ui.unit.dp 21 | import pl.lambada.songsync.R 22 | import pl.lambada.songsync.ui.components.SettingsHeadLabel 23 | 24 | 25 | @Composable 26 | fun CreditsSection(uriHandler: UriHandler) { 27 | Column{ 28 | val credits = mapOf( 29 | stringResource(R.string.spotify_api) to "https://developer.spotify.com/documentation/web-api", 30 | stringResource(R.string.spotifylyrics_api) to "https://github.com/akashrchandran/spotify-lyrics-api", 31 | stringResource(R.string.syncedlyrics_py) to "https://github.com/0x7d4/syncedlyrics", 32 | stringResource(R.string.statusbar_lyrics_ext) to "https://github.com/cjybyjk/StatusBarLyricExt" 33 | ) 34 | credits.forEach { credit -> 35 | Row( 36 | modifier = Modifier 37 | .fillMaxWidth() 38 | .clickable { uriHandler.openUri(credit.value) } 39 | .padding(22.dp), 40 | verticalAlignment = Alignment.CenterVertically 41 | ) { 42 | Icon( 43 | imageVector = Icons.AutoMirrored.Filled.OpenInNew, 44 | contentDescription = stringResource(id = R.string.open_website) 45 | ) 46 | Spacer(modifier = Modifier.width(16.dp)) 47 | Text(text = credit.key) 48 | } 49 | } 50 | } 51 | Spacer(modifier = Modifier.height(16.dp)) 52 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/ExternalLinkSection.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.platform.UriHandler 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.unit.dp 13 | import androidx.compose.ui.unit.sp 14 | import pl.lambada.songsync.R 15 | import pl.lambada.songsync.ui.components.SettingsHeadLabel 16 | 17 | 18 | @Composable 19 | fun ExternalLinkSection(url: String, uriHandler: UriHandler) { 20 | Column( 21 | modifier = Modifier 22 | .clickable { uriHandler.openUri(url) } 23 | .padding(horizontal = 22.dp, vertical = 16.dp) 24 | ) { 25 | Text(stringResource(R.string.we_are_open_source)) 26 | Text( 27 | text = stringResource(R.string.view_on_github), 28 | color = MaterialTheme.colorScheme.onSurfaceVariant, 29 | fontSize = 12.sp, 30 | lineHeight = 16.sp, 31 | ) 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/MarqueeSwitch.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import pl.lambada.songsync.R 6 | import pl.lambada.songsync.ui.components.SwitchItem 7 | 8 | 9 | @Composable 10 | fun MarqueeSwitch(selected: Boolean, onToggle: (Boolean) -> Unit) { 11 | SwitchItem( 12 | label = stringResource(R.string.disable_marquee), 13 | description = stringResource(R.string.disable_marquee_summary), 14 | selected = selected, 15 | onClick = { onToggle(!selected) } 16 | ) 17 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/MultiPersonSwitch.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import pl.lambada.songsync.R 6 | import pl.lambada.songsync.ui.components.SwitchItem 7 | 8 | @Composable 9 | fun MultiPersonSwitch(selected: Boolean, onToggle: (Boolean) -> Unit) { 10 | SwitchItem( 11 | label = stringResource(id = R.string.multi_person_word_by_word), 12 | description = stringResource(id = R.string.multi_person_word_by_word_summary2), 13 | selected = selected, 14 | onClick = { onToggle(!selected) } 15 | ) 16 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/OffsetModeSwitch.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import pl.lambada.songsync.R 6 | import pl.lambada.songsync.ui.components.SwitchItem 7 | 8 | @Composable 9 | fun OffsetModeSwitch( 10 | selected: Boolean, 11 | onToggle: (Boolean) -> Unit 12 | ) { 13 | SwitchItem( 14 | label = stringResource(R.string.offset_mode), 15 | description = stringResource(R.string.offset_mode_summary), 16 | selected = selected, 17 | onClick = { onToggle(!selected) } 18 | ) 19 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/PureBlackThemeSwitch.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import pl.lambada.songsync.R 6 | import pl.lambada.songsync.ui.components.SwitchItem 7 | 8 | @Composable 9 | fun PureBlackThemeSwitch(selected: Boolean, onToggle: (Boolean) -> Unit) { 10 | SwitchItem( 11 | label = stringResource(R.string.pure_black_theme), 12 | description = stringResource(R.string.pure_black_theme_summary), 13 | selected = selected, 14 | onClick = { onToggle(!selected) } 15 | ) 16 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/RomanizationSwitch.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import pl.lambada.songsync.R 6 | import pl.lambada.songsync.ui.components.SwitchItem 7 | 8 | @Composable 9 | fun RomanizationSwitch(selected: Boolean, onToggle: (Boolean) -> Unit) { 10 | SwitchItem( 11 | label = stringResource(id = R.string.include_romanization), 12 | description = stringResource(id = R.string.include_romanization_summary), 13 | selected = selected, 14 | onClick = { onToggle(!selected) } 15 | ) 16 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/SdCardPathSetting.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import android.net.Uri 4 | import android.os.Environment 5 | import androidx.activity.compose.rememberLauncherForActivityResult 6 | import androidx.activity.result.contract.ActivityResultContracts 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.width 13 | import androidx.compose.material3.Button 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.OutlinedButton 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.LaunchedEffect 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.runtime.mutableStateOf 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.runtime.setValue 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.res.stringResource 25 | import androidx.compose.ui.unit.dp 26 | import androidx.compose.ui.unit.sp 27 | import pl.lambada.songsync.R 28 | 29 | 30 | @Composable 31 | fun SdCardPathSetting(sdPath: String?, onClearPath: () -> Unit, onUpdatePath: (String) -> Unit) { 32 | var picker by remember { mutableStateOf(false) } 33 | Column( 34 | modifier = Modifier.padding(horizontal = 22.dp, vertical = 16.dp) 35 | ) { 36 | Text(stringResource(R.string.set_sd_path)) 37 | Text( 38 | text = if (sdPath.isNullOrEmpty()) stringResource(R.string.no_sd_card_path_set) 39 | else stringResource(R.string.sd_card_path_set_successfully), 40 | color = if (sdPath.isNullOrEmpty()) MaterialTheme.colorScheme.error 41 | else MaterialTheme.colorScheme.tertiary, 42 | fontSize = 12.sp 43 | ) 44 | Spacer(modifier = Modifier.height(8.dp)) 45 | 46 | Row { 47 | Spacer(modifier = Modifier.weight(1f)) 48 | OutlinedButton(onClick = onClearPath) { 49 | Text(stringResource(R.string.clear_sd_card_path)) 50 | } 51 | Spacer(modifier = Modifier.width(12.dp)) 52 | Button(onClick = { picker = true }) { 53 | Text(stringResource(R.string.set_sd_card_path)) 54 | } 55 | } 56 | 57 | if (picker) { 58 | val sdCardPicker = 59 | rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { 60 | it?.let { uri -> onUpdatePath(uri.toString()) } 61 | picker = false 62 | } 63 | LaunchedEffect(Unit) { 64 | sdCardPicker.launch(Uri.parse(Environment.getExternalStorageDirectory().absolutePath)) 65 | } 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/SettingsScreenTopBar.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 6 | import androidx.compose.material3.ExperimentalMaterial3Api 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.IconButton 9 | import androidx.compose.material3.MediumTopAppBar 10 | import androidx.compose.material3.Text 11 | import androidx.compose.material3.TopAppBarScrollBehavior 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.unit.dp 16 | import androidx.navigation.NavController 17 | import pl.lambada.songsync.R 18 | 19 | @OptIn(ExperimentalMaterial3Api::class) 20 | @Composable 21 | fun SettingsScreenTopBar(navController: NavController, scrollBehavior: TopAppBarScrollBehavior) { 22 | MediumTopAppBar( 23 | navigationIcon = { 24 | IconButton( 25 | onClick = { 26 | navController.popBackStack(navController.graph.startDestinationId, false) 27 | } 28 | ) { 29 | Icon( 30 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 31 | contentDescription = stringResource(R.string.back), 32 | ) 33 | } 34 | }, 35 | title = { 36 | Text( 37 | modifier = Modifier.padding(start = 6.dp), 38 | text = stringResource(id = R.string.settings) 39 | ) 40 | }, 41 | scrollBehavior = scrollBehavior 42 | ) 43 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/ShowPathSwitch.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import pl.lambada.songsync.R 6 | import pl.lambada.songsync.ui.components.SwitchItem 7 | 8 | @Composable 9 | fun ShowPathSwitch(selected: Boolean, onToggle: (Boolean) -> Unit) { 10 | SwitchItem( 11 | label = stringResource(R.string.song_path), 12 | description = stringResource(R.string.song_path_description), 13 | selected = selected, 14 | onClick = { onToggle(!selected) } 15 | ) 16 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/SupportSection.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.platform.UriHandler 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.unit.dp 13 | import androidx.compose.ui.unit.sp 14 | import pl.lambada.songsync.R 15 | import pl.lambada.songsync.ui.components.SettingsHeadLabel 16 | 17 | 18 | @Composable 19 | fun SupportSection(uriHandler: UriHandler) { 20 | Column( 21 | modifier = Modifier 22 | .clickable { uriHandler.openUri("https://t.me/LambadaOT") } 23 | .padding(horizontal = 22.dp, vertical = 16.dp) 24 | ) { 25 | Text(stringResource(R.string.bugs_or_suggestions_contact_us)) 26 | Text( 27 | text = stringResource(R.string.telegram_group), 28 | color = MaterialTheme.colorScheme.onSurfaceVariant, 29 | fontSize = 12.sp, 30 | lineHeight = 16.sp, 31 | ) 32 | } 33 | Text( 34 | stringResource(R.string.create_issue), 35 | modifier = Modifier.padding(horizontal = 22.dp) 36 | ) 37 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/SyncedLyricsSwitch.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import pl.lambada.songsync.R 6 | import pl.lambada.songsync.ui.components.SwitchItem 7 | 8 | @Composable 9 | fun SyncedLyricsSwitch(selected: Boolean, onToggle: (Boolean) -> Unit) { 10 | SwitchItem( 11 | label = stringResource(id = R.string.synced_lyrics), 12 | description = stringResource(id = R.string.synced_lyrics_summary), 13 | selected = selected, 14 | onClick = { onToggle(!selected) } 15 | ) 16 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/TranslationSection.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.platform.UriHandler 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.unit.dp 13 | import androidx.compose.ui.unit.sp 14 | import pl.lambada.songsync.ui.components.SettingsHeadLabel 15 | import pl.lambada.songsync.R 16 | 17 | @Composable 18 | fun TranslationSection(uriHandler: UriHandler) { 19 | Column( 20 | modifier = Modifier 21 | .clickable { uriHandler.openUri("https://hosted.weblate.org/engage/songsync/") } 22 | .padding(horizontal = 22.dp, vertical = 16.dp) 23 | ) { 24 | Text(stringResource(id = R.string.help_us_translate)) 25 | Text( 26 | text = stringResource(id = R.string.translation_website), 27 | color = MaterialTheme.colorScheme.onSurfaceVariant, 28 | fontSize = 12.sp, 29 | lineHeight = 16.sp, 30 | ) 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/TranslationSwitch.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.res.stringResource 5 | import pl.lambada.songsync.R 6 | import pl.lambada.songsync.ui.components.SwitchItem 7 | 8 | 9 | @Composable 10 | fun TranslationSwitch(selected: Boolean, onToggle: (Boolean) -> Unit) { 11 | SwitchItem( 12 | label = stringResource(id = R.string.include_translation), 13 | description = stringResource(id = R.string.include_translation_summary), 14 | selected = selected, 15 | onClick = { onToggle(!selected) } 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/UpdateAvailableDialog.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.rememberScrollState 5 | import androidx.compose.foundation.verticalScroll 6 | import androidx.compose.material3.AlertDialog 7 | import androidx.compose.material3.Button 8 | import androidx.compose.material3.OutlinedButton 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.res.stringResource 13 | import pl.lambada.songsync.R 14 | 15 | @Composable 16 | fun UpdateAvailableDialog( 17 | onDismiss: () -> Unit, 18 | currentVersion: String, 19 | latestVersion: String, 20 | changelog: String, 21 | onDownloadRequest: () -> Unit, 22 | ) { 23 | AlertDialog( 24 | onDismissRequest = onDismiss, 25 | title = { Text(stringResource(R.string.update_available)) }, 26 | text = { 27 | Column(Modifier.verticalScroll(rememberScrollState())) { 28 | Text("v$currentVersion -> $latestVersion") 29 | Text(stringResource(R.string.changelog, changelog)) 30 | } 31 | }, 32 | confirmButton = { 33 | Button(onDownloadRequest) { Text(stringResource(R.string.download)) } 34 | }, 35 | dismissButton = { 36 | OutlinedButton(onDismiss) { Text(stringResource(R.string.cancel)) } 37 | } 38 | ) 39 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/Utils.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.screens.settings.components 2 | 3 | import pl.lambada.songsync.R 4 | 5 | @Suppress("SpellCheckingInspection") 6 | enum class Contributor( 7 | val devName: String, 8 | val contributionLevel: ContributionLevel, 9 | val github: String? = null, 10 | val telegram: String? = null 11 | ) { 12 | LAMBADA10( 13 | "Lambada10", ContributionLevel.LEAD_DEVELOPER, 14 | github = "https://github.com/Lambada10", telegram = "https://t.me/Lambada10" 15 | ), 16 | NIFT4( 17 | "Nick", ContributionLevel.DEVELOPER, 18 | github = "https://github.com/nift4", telegram = "https://t.me/nift4" 19 | ), 20 | BOBBYESP( 21 | "BobbyESP", ContributionLevel.DEVELOPER, 22 | github = "https://github.com/BobbyESP" 23 | ), 24 | PXEEMO( 25 | "Pxeemo", ContributionLevel.CONTRIBUTOR, 26 | github = "https://github.com/pxeemo" 27 | ), 28 | AKANETAN( 29 | "AkaneTan", ContributionLevel.CONTRIBUTOR, 30 | github = "https://github.com/AkaneTan" 31 | ), 32 | NXOIM( 33 | devName = "nxoim", ContributionLevel.CONTRIBUTOR, 34 | github = "https://github.com/nxoim" 35 | ), 36 | PAXSENIX0( 37 | devName = "Paxsenix0", ContributionLevel.CONTRIBUTOR, 38 | github = "https://github.com/paxsenix0" 39 | ) 40 | } 41 | 42 | /** 43 | * Defines the contribution level of a contributor. 44 | */ 45 | enum class ContributionLevel(val stringResource: Int) { 46 | CONTRIBUTOR(R.string.contributor), 47 | DEVELOPER(R.string.developer), 48 | LEAD_DEVELOPER(R.string.lead_developer) 49 | } 50 | 51 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.darkColorScheme 9 | import androidx.compose.material3.dynamicDarkColorScheme 10 | import androidx.compose.material3.dynamicLightColorScheme 11 | import androidx.compose.material3.lightColorScheme 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.LaunchedEffect 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.platform.LocalContext 16 | import androidx.compose.ui.platform.LocalView 17 | import androidx.compose.ui.unit.dp 18 | import androidx.core.view.WindowCompat 19 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 20 | 21 | private val darkColorScheme = darkColorScheme( 22 | primary = Purple80, 23 | secondary = PurpleGrey80, 24 | tertiary = Pink80 25 | ) 26 | 27 | private val lightColorScheme = lightColorScheme( 28 | primary = Purple40, 29 | secondary = PurpleGrey40, 30 | tertiary = Pink40 31 | ) 32 | 33 | /** 34 | * Custom SongSync theme that applies the desired color scheme and system UI adjustments. 35 | * 36 | * @param darkTheme Whether to use dark theme based on system settings. 37 | * @param dynamicColor Whether to use dynamic color scheme (available on Android 12+). 38 | * @param content The content of the theme. 39 | */ 40 | @Composable 41 | fun SongSyncTheme( 42 | darkTheme: Boolean = isSystemInDarkTheme(), 43 | pureBlack: Boolean = false, 44 | dynamicColor: Boolean = true, 45 | content: @Composable () -> Unit 46 | ) { 47 | val context = LocalContext.current 48 | val colorScheme = when { 49 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 50 | if (darkTheme) 51 | if (pureBlack) 52 | dynamicDarkColorScheme(context).copy( 53 | surface = Color.Black, 54 | background = Color.Black, 55 | ) 56 | else 57 | dynamicDarkColorScheme(context) 58 | else 59 | dynamicLightColorScheme(context) 60 | } 61 | 62 | pureBlack -> darkColorScheme.copy( 63 | surface = Color.Black, 64 | background = Color.Black, 65 | ) 66 | 67 | darkTheme -> darkColorScheme 68 | else -> lightColorScheme 69 | } 70 | val sysUiController = rememberSystemUiController() 71 | val view = LocalView.current 72 | if (!view.isInEditMode) { 73 | LaunchedEffect(Unit) { 74 | val window = (view.context as Activity).window 75 | WindowCompat.setDecorFitsSystemWindows(window, false) 76 | sysUiController.setSystemBarsColor( 77 | color = Color.Transparent, darkIcons = !darkTheme, 78 | isNavigationBarContrastEnforced = false 79 | ) 80 | } 81 | } 82 | 83 | MaterialTheme( 84 | colorScheme = colorScheme, 85 | typography = Typography, 86 | content = content, 87 | shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(16.dp)), 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | ) -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/DataStorePreferences.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.MutableState 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.runtime.rememberCoroutineScope 9 | import androidx.compose.ui.platform.LocalContext 10 | import androidx.datastore.core.DataStore 11 | import androidx.datastore.preferences.SharedPreferencesMigration 12 | import androidx.datastore.preferences.core.Preferences 13 | import androidx.datastore.preferences.core.edit 14 | import androidx.datastore.preferences.preferencesDataStore 15 | import kotlinx.coroutines.Dispatchers 16 | import kotlinx.coroutines.flow.distinctUntilChanged 17 | import kotlinx.coroutines.flow.first 18 | import kotlinx.coroutines.flow.map 19 | import kotlinx.coroutines.launch 20 | import kotlinx.coroutines.runBlocking 21 | import pl.lambada.songsync.util.ext.toEnum 22 | import kotlin.properties.ReadOnlyProperty 23 | 24 | val Context.dataStore: DataStore by preferencesDataStore( 25 | name = "settings", 26 | produceMigrations = { context -> 27 | listOf(SharedPreferencesMigration(context, "pl.lambada.songsync_preferences")) 28 | } 29 | ) 30 | 31 | operator fun DataStore.get(key: Preferences.Key): T? = 32 | runBlocking(Dispatchers.IO) { 33 | data.first()[key] 34 | } 35 | 36 | fun DataStore.get(key: Preferences.Key, defaultValue: T): T = 37 | runBlocking(Dispatchers.IO) { 38 | data.first()[key] ?: defaultValue 39 | } 40 | 41 | @JvmName("getNullable") 42 | fun DataStore.get(key: Preferences.Key, defaultValue: T?): T? = 43 | runBlocking(Dispatchers.IO) { 44 | data.first()[key] ?: defaultValue 45 | } 46 | 47 | fun DataStore.set(key: Preferences.Key, value: T) = 48 | runBlocking(Dispatchers.IO) { 49 | edit { it[key] = value } 50 | } 51 | 52 | fun preference( 53 | context: Context, 54 | key: Preferences.Key, 55 | defaultValue: T, 56 | ) = ReadOnlyProperty { _, _ -> context.dataStore[key] ?: defaultValue } 57 | 58 | inline fun > enumPreference( 59 | context: Context, 60 | key: Preferences.Key, 61 | defaultValue: T, 62 | ) = ReadOnlyProperty { _, _ -> context.dataStore[key].toEnum(defaultValue) } 63 | 64 | @Composable 65 | fun rememberPreference( 66 | key: Preferences.Key, 67 | defaultValue: T, 68 | ): MutableState { 69 | val context = LocalContext.current 70 | val coroutineScope = rememberCoroutineScope() 71 | 72 | val state = remember { 73 | context.dataStore.data 74 | .map { it[key] ?: defaultValue } 75 | .distinctUntilChanged() 76 | }.collectAsState(context.dataStore[key] ?: defaultValue) 77 | 78 | return remember { 79 | object : MutableState { 80 | override var value: T 81 | get() = state.value 82 | set(value) { 83 | coroutineScope.launch { 84 | context.dataStore.edit { 85 | it[key] = value 86 | } 87 | } 88 | } 89 | 90 | override fun component1() = value 91 | override fun component2(): (T) -> Unit = { value = it } 92 | } 93 | } 94 | } 95 | 96 | @Composable 97 | inline fun > rememberEnumPreference( 98 | key: Preferences.Key, 99 | defaultValue: T, 100 | ): MutableState { 101 | val context = LocalContext.current 102 | val coroutineScope = rememberCoroutineScope() 103 | 104 | val initialValue = context.dataStore[key].toEnum(defaultValue = defaultValue) 105 | val state = remember { 106 | context.dataStore.data 107 | .map { it[key].toEnum(defaultValue = defaultValue) } 108 | .distinctUntilChanged() 109 | }.collectAsState(initialValue) 110 | 111 | return remember { 112 | object : MutableState { 113 | override var value: T 114 | get() = state.value 115 | set(value) { 116 | coroutineScope.launch { 117 | context.dataStore.edit { 118 | it[key] = value.name 119 | } 120 | } 121 | } 122 | 123 | override fun component1() = value 124 | override fun component2(): (T) -> Unit = { value = it } 125 | } 126 | } 127 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/Exceptions.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util 2 | 3 | class NoTrackFoundException : Exception() 4 | class InternalErrorException(msg: String) : Exception(msg) 5 | class EmptyQueryException : Exception() -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/MiscelaneousUtils.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Build 7 | import android.widget.Toast 8 | import androidx.core.content.FileProvider 9 | import java.io.File 10 | import java.nio.file.Files 11 | 12 | fun isLegacyFileAccessRequired(filePath: String?): Boolean { 13 | // Before Android 11, not in internal storage 14 | return Build.VERSION.SDK_INT < Build.VERSION_CODES.R 15 | && filePath?.contains("/storage/emulated/0/") == false 16 | } 17 | 18 | fun openFileFromPath(context: Context, filePath: String) { 19 | val file = File(filePath) 20 | if (!file.exists()) { 21 | showToast(context, "File does not exist") 22 | return 23 | } 24 | 25 | val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 26 | FileProvider.getUriForFile(context, context.packageName + ".provider", file) 27 | } else { 28 | Uri.fromFile(file) 29 | } 30 | 31 | val mime = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 32 | Files.probeContentType(file.toPath()) 33 | } else run { 34 | val extension = file.extension 35 | val mimeTypeMap = android.webkit.MimeTypeMap.getSingleton() 36 | mimeTypeMap.getMimeTypeFromExtension(extension) 37 | } 38 | 39 | val intent = Intent(Intent.ACTION_VIEW) 40 | .setDataAndType(uri, mime) 41 | .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 42 | 43 | if (intent.resolveActivity(context.packageManager) != null) { 44 | context.startActivity(intent) 45 | } else { 46 | Toast.makeText(context, "No app found to open the music file.", Toast.LENGTH_SHORT).show() 47 | } 48 | } 49 | 50 | fun showToast(context: Context, messageResId: Int, vararg args: Any, long: Boolean = true) { 51 | Toast 52 | .makeText( 53 | context, 54 | context.getString(messageResId, *args), 55 | if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT 56 | ) 57 | .show() 58 | } 59 | 60 | fun showToast(context: Context, message: String, long: Boolean = true) { 61 | Toast 62 | .makeText( 63 | context, 64 | message, 65 | if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT 66 | ) 67 | .show() 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/ResourceState.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util 2 | 3 | /** 4 | * A sealed class representing the state of a resource. 5 | * 6 | * @param T The type of data associated with the resource state. 7 | * @property data The data associated with the resource state. 8 | * @property message An optional message associated with the resource state. 9 | */ 10 | sealed class ResourceState(val data: T? = null, val message: String? = null) { 11 | 12 | /** 13 | * Represents a loading state. 14 | * 15 | * @param T The type of data. 16 | * @property data The data associated with the loading state. 17 | */ 18 | class Loading(data: T? = null) : ResourceState(data) 19 | 20 | /** 21 | * Represents a success state with optional data. 22 | * 23 | * @param T The type of data. 24 | * @property data The data associated with the success state. 25 | */ 26 | class Success(data: T?) : ResourceState(data) 27 | 28 | /** 29 | * Represents an error state with an optional message and data. 30 | * 31 | * @param T The type of data. 32 | * @property message The message associated with the error state. 33 | * @property data The data associated with the error state. 34 | */ 35 | class Error(message: String, data: T? = null) : ResourceState(data, message) 36 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/ScreenState.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util 2 | 3 | /** 4 | * A sealed class representing the state of a screen. 5 | * 6 | * @param T The type of data associated with the success state. 7 | */ 8 | sealed class ScreenState { 9 | /** 10 | * Represents a loading state. 11 | */ 12 | data object Loading : ScreenState() 13 | 14 | /** 15 | * Represents a success state with optional data. 16 | * 17 | * @param T The type of data. 18 | * @property data The data associated with the success state. 19 | */ 20 | data class Success(val data: T?) : ScreenState() 21 | 22 | /** 23 | * Represents an error state with an exception. 24 | * 25 | * @property exception The exception associated with the error state. 26 | */ 27 | data class Error(val exception: Throwable) : ScreenState() 28 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/ext/ComposeExt.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util.ext 2 | 3 | import androidx.activity.OnBackPressedCallback 4 | import androidx.activity.OnBackPressedDispatcher 5 | import androidx.activity.compose.LocalOnBackPressedDispatcherOwner 6 | import androidx.compose.foundation.gestures.awaitEachGesture 7 | import androidx.compose.foundation.gestures.awaitFirstDown 8 | import androidx.compose.foundation.gestures.waitForUpOrCancellation 9 | import androidx.compose.foundation.indication 10 | import androidx.compose.foundation.interaction.MutableInteractionSource 11 | import androidx.compose.foundation.interaction.PressInteraction 12 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 13 | import androidx.compose.foundation.layout.WindowInsets 14 | import androidx.compose.foundation.layout.isImeVisible 15 | import androidx.compose.material3.ripple 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.DisposableEffect 18 | import androidx.compose.runtime.getValue 19 | import androidx.compose.runtime.remember 20 | import androidx.compose.runtime.rememberCoroutineScope 21 | import androidx.compose.runtime.rememberUpdatedState 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.composed 24 | import androidx.compose.ui.input.pointer.pointerInput 25 | import kotlinx.coroutines.delay 26 | import kotlinx.coroutines.launch 27 | 28 | @OptIn(ExperimentalLayoutApi::class) 29 | @Composable 30 | fun BackPressHandler( 31 | backPressedDispatcher: OnBackPressedDispatcher? = 32 | LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher, 33 | enabled: Boolean = true, 34 | onBackPressed: () -> Unit 35 | ) { 36 | val realEnabled = enabled && !WindowInsets.isImeVisible 37 | val currentOnBackPressed by rememberUpdatedState(newValue = onBackPressed) 38 | 39 | val backCallback = remember(key1 = backPressedDispatcher, key2 = realEnabled) { 40 | object : OnBackPressedCallback(realEnabled) { 41 | override fun handleOnBackPressed() { 42 | currentOnBackPressed() 43 | } 44 | } 45 | } 46 | 47 | DisposableEffect(key1 = backPressedDispatcher, key2 = realEnabled) { 48 | backPressedDispatcher?.addCallback(backCallback) 49 | 50 | onDispose { 51 | backCallback.remove() 52 | } 53 | } 54 | } 55 | 56 | fun Modifier.repeatingClickable( 57 | interactionSource: MutableInteractionSource, 58 | enabled: Boolean, 59 | maxDelayMillis: Long = 1000, 60 | minDelayMillis: Long = 5, 61 | delayDecayFactor: Float = .20f, 62 | onClick: () -> Unit 63 | ): Modifier = this.then( 64 | composed { 65 | val currentClickListener by rememberUpdatedState(onClick) 66 | val scope = rememberCoroutineScope() 67 | 68 | pointerInput(interactionSource, enabled) { 69 | scope.launch { 70 | awaitEachGesture { 71 | val down = awaitFirstDown(requireUnconsumed = false) 72 | // Create a down press interaction 73 | val downPress = PressInteraction.Press(down.position) 74 | val heldButtonJob = launch { 75 | // Send the press through the interaction source 76 | interactionSource.emit(downPress) 77 | var currentDelayMillis = maxDelayMillis 78 | while (enabled && down.pressed) { 79 | currentClickListener() 80 | delay(currentDelayMillis) 81 | val nextMillis = currentDelayMillis - (currentDelayMillis * delayDecayFactor) 82 | currentDelayMillis = nextMillis.toLong().coerceAtLeast(minDelayMillis) 83 | } 84 | } 85 | val up = waitForUpOrCancellation() 86 | heldButtonJob.cancel() 87 | // Determine whether a cancel or release occurred, and create the interaction 88 | val releaseOrCancel = when (up) { 89 | null -> PressInteraction.Cancel(downPress) 90 | else -> PressInteraction.Release(downPress) 91 | } 92 | launch { 93 | // Send the result through the interaction source 94 | interactionSource.emit(releaseOrCancel) 95 | } 96 | } 97 | } 98 | } 99 | } 100 | ) -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/ext/ContextExt.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util.ext 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageInfo 5 | import android.content.pm.PackageManager 6 | import android.os.Build 7 | 8 | /** 9 | * Extension function to get the version name of the application. 10 | * 11 | * @return The version name as a [String]. 12 | */ 13 | fun Context.getVersion(): String { 14 | val pInfo: PackageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 15 | packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) 16 | } else { 17 | @Suppress("deprecation") 18 | packageManager.getPackageInfo(packageName, 0) 19 | } 20 | return pInfo.versionName.toString() 21 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/ext/FileExt.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util.ext 2 | 3 | import android.util.Log 4 | import java.io.File 5 | 6 | /** 7 | * Replace all characters in the file name that are not allowed to exist in a file name with an underscore. 8 | * 9 | * @return The sanitized [File] instance. 10 | */ 11 | fun File.sanitize(): File { 12 | return File(this.parent, this.name.replace(Regex("[/\\\\:*?\"<>|\\t\\n]"), "_")) 13 | } 14 | 15 | /** 16 | * Converts the file path to an LRC file path. 17 | * 18 | * @return The [File] instance with the LRC extension. 19 | */ 20 | fun String.toLrcFile(): File? { 21 | return if (this.isNotEmpty()) { 22 | File(this.substringBeforeLast('.') + ".lrc") 23 | } else { 24 | null 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/ext/IntExt.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util.ext 2 | 3 | fun Int.toLrcTimestamp(): String { 4 | val minutes = this / 60000 5 | val seconds = (this % 60000) / 1000 6 | val milliseconds = this % 1000 7 | 8 | val leadingZeros: Array = arrayOf( 9 | if (minutes < 10) "0" else "", 10 | if (seconds < 10) "0" else "", 11 | if (milliseconds < 10) "00" else if (milliseconds < 100) "0" else "" 12 | ) 13 | 14 | return "${leadingZeros[0]}$minutes:${leadingZeros[1]}$seconds.${leadingZeros[2]}$milliseconds" 15 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/ext/StringExt.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util.ext 2 | 3 | import java.io.File 4 | import java.util.Locale 5 | 6 | fun String.lowercaseWithLocale(): String { 7 | return this.lowercase(Locale.getDefault()) 8 | } 9 | 10 | fun String?.toLrcFile(): File? { 11 | if (this == null) return null 12 | val idx = lastIndexOf('.') 13 | return File( 14 | substring( 15 | 0, 16 | if (idx == -1) length else idx 17 | ) + ".lrc" 18 | ) 19 | } 20 | 21 | inline fun > String?.toEnum(defaultValue: T): T = 22 | if (this == null) defaultValue 23 | else try { 24 | enumValueOf(this) 25 | } catch (e: IllegalArgumentException) { 26 | defaultValue 27 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/networking/Ktor.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util.networking 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.engine.cio.CIO 5 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 6 | import io.ktor.serialization.kotlinx.json.json 7 | import kotlinx.serialization.json.Json 8 | 9 | object Ktor { 10 | val client = HttpClient(CIO.create { 11 | // Here goes all the Engine config 12 | // TODO: Add proxy support 13 | // proxy = ProxyConfig( 14 | // type = Proxy.Type.SOCKS, 15 | // sa = java.net.InetSocketAddress(3030) 16 | // ) 17 | }) { 18 | // In case of adding plugins, add them here 19 | install(ContentNegotiation) { 20 | json(Json { 21 | ignoreUnknownKeys = true 22 | encodeDefaults = true 23 | }) 24 | } 25 | } 26 | 27 | val json = Json { 28 | ignoreUnknownKeys = true 29 | encodeDefaults = true 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/ui/AnimationSpecs.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util.ui 2 | 3 | import android.graphics.Path 4 | import android.view.animation.PathInterpolator 5 | import androidx.compose.animation.BoundsTransform 6 | import androidx.compose.animation.ExperimentalSharedTransitionApi 7 | import androidx.compose.animation.core.ArcMode 8 | import androidx.compose.animation.core.CubicBezierEasing 9 | import androidx.compose.animation.core.Easing 10 | import androidx.compose.animation.core.ExperimentalAnimationSpecApi 11 | import androidx.compose.animation.core.keyframes 12 | import androidx.compose.animation.core.tween 13 | import pl.lambada.songsync.util.ui.MotionConstants.DURATION 14 | import pl.lambada.songsync.util.ui.MotionConstants.DURATION_ENTER 15 | import pl.lambada.songsync.util.ui.MotionConstants.DURATION_ENTER_SHORT 16 | import pl.lambada.songsync.util.ui.MotionConstants.DURATION_EXIT 17 | import pl.lambada.songsync.util.ui.MotionConstants.DURATION_EXIT_SHORT 18 | 19 | fun PathInterpolator.toEasing(): Easing { 20 | return Easing { f -> this.getInterpolation(f) } 21 | } 22 | 23 | private val path = Path().apply { 24 | moveTo(0f, 0f) 25 | cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F) 26 | cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F) 27 | } 28 | 29 | val EmphasizedPathInterpolator = PathInterpolator(path) 30 | val EmphasizedEasing = EmphasizedPathInterpolator.toEasing() 31 | 32 | val EmphasizeEasingVariant = CubicBezierEasing(.2f, 0f, 0f, 1f) 33 | val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) 34 | val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 1f, 1f) 35 | val EmphasizedDecelerateEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) 36 | val EmphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f) 37 | 38 | val StandardDecelerate = CubicBezierEasing(.0f, .0f, 0f, 1f) 39 | val MotionEasingStandard = CubicBezierEasing(0.4F, 0.0F, 0.2F, 1F) 40 | 41 | val tweenSpec = tween(durationMillis = DURATION_ENTER, easing = EmphasizedEasing) 42 | 43 | fun tweenEnter( 44 | delayMillis: Int = DURATION_EXIT, 45 | durationMillis: Int = DURATION_ENTER 46 | ) = 47 | tween( 48 | delayMillis = delayMillis, 49 | durationMillis = durationMillis, 50 | easing = EmphasizedDecelerateEasing 51 | ) 52 | 53 | fun tweenExit( 54 | durationMillis: Int = DURATION_EXIT_SHORT, 55 | ) = tween( 56 | durationMillis = durationMillis, 57 | easing = EmphasizedAccelerateEasing 58 | ) 59 | 60 | @OptIn(ExperimentalSharedTransitionApi::class) 61 | val DefaultBoundsTransform = BoundsTransform { _, _ -> 62 | tween(easing = EmphasizedEasing, durationMillis = DURATION) 63 | } 64 | 65 | @OptIn(ExperimentalAnimationSpecApi::class, ExperimentalSharedTransitionApi::class) 66 | val SearchFABBoundsTransform = BoundsTransform { initialBounds, targetBounds -> 67 | keyframes { 68 | durationMillis = DURATION_ENTER_SHORT 69 | initialBounds at 0 using ArcMode.ArcBelow using MotionEasingStandard 70 | targetBounds at DURATION_ENTER_SHORT using ArcMode.ArcAbove using MotionEasingStandard 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /app/src/main/java/pl/lambada/songsync/util/ui/MotionConstants.kt: -------------------------------------------------------------------------------- 1 | package pl.lambada.songsync.util.ui 2 | 3 | /* 4 | * Copyright 2021 SOUP 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * https://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | 20 | import androidx.compose.ui.unit.Dp 21 | import androidx.compose.ui.unit.dp 22 | 23 | object MotionConstants { 24 | const val DefaultMotionDuration: Int = 300 25 | const val DefaultFadeInDuration: Int = 150 26 | const val DefaultFadeOutDuration: Int = 75 27 | val DefaultSlideDistance: Dp = 30.dp 28 | 29 | const val DURATION = 600 30 | const val DURATION_ENTER = 400 31 | const val DURATION_ENTER_SHORT = 300 32 | const val DURATION_EXIT = 200 33 | const val DURATION_EXIT_SHORT = 100 34 | 35 | const val InitialOffset = 0.10f 36 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v31/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v31/ic_notification.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v31/ic_song.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | 11 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_notification.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_song.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v31/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v31/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/resources.properties: -------------------------------------------------------------------------------- 1 | unqualifiedResLocale = en-US -------------------------------------------------------------------------------- /app/src/main/res/values-pl/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/values-v31/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #EADDFF 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 11 | 12 | 13 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/xml/file_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 |

SongSync is a simple Android app to download lyrics for songs in your music library.


Features:

  • Download lyrics for whole music library with a single click
  • Download lyrics for individual songs in your music library
  • Embed lyrics directly to songs
  • Download lyrics from various providers
  • Search for lyrics for songs not in your music library (and download them)
-------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot1.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot2.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot3.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot4.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot5.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Android app to download lyrics for songs in your music library. -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | ## For more details on how to configure your build environment visit 2 | # http://www.gradle.org/docs/current/userguide/build_environment.html 3 | # 4 | # Specifies the JVM arguments used for the daemon process. 5 | # The setting is particularly useful for tweaking memory settings. 6 | # Default value: -Xmx1024m -XX:MaxPermSize=256m 7 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 8 | # 9 | # When configured, Gradle will run in incubating parallel mode. 10 | # This option should only be used with decoupled projects. For more details, visit 11 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects 12 | # org.gradle.parallel=true 13 | #Wed May 22 20:46:42 IRST 2024 14 | android.nonFinalResIds=false 15 | android.nonTransitiveRClass=true 16 | android.useAndroidX=true 17 | kotlin.code.style=official 18 | org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" -Dfile.encoding\=UTF-8 --add-opens=java.base/java.lang=ALL-UNNAMED 19 | org.gradle.unsafe.configuration-cache=true 20 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | accompanist-systemuicontroller = "0.34.0" 3 | accompanist-permissions = "0.34.0" 4 | agp = "8.7.1" 5 | coil-compose = "2.7.0" 6 | kotlin = "2.0.0" 7 | core-ktx = "1.13.1" 8 | material3 = "1.3.0" 9 | kotlinx-coroutines-android = "1.8.1" 10 | kotlinx-serialization-json = "1.6.3" 11 | lifecycle-runtime-ktx = "2.8.6" 12 | activity-compose = "1.9.3" 13 | compose-bom = "2024.10.00" 14 | compose-animation = "1.7.4" 15 | material-icons-extended = "1.7.4" 16 | navigation-compose = "2.8.3" 17 | navigation-runtime-ktx = "2.8.3" 18 | preference = "1.2.1" 19 | ktor = "2.3.6" 20 | datastore = "1.1.1" 21 | taglib = "1.0.0-alpha18" 22 | kotlin-onetimepassword = "2.4.1" 23 | 24 | [libraries] 25 | accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist-permissions" } 26 | accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist-systemuicontroller" } 27 | androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "material-icons-extended" } 28 | androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } 29 | coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } 30 | core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } 31 | kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } 32 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } 33 | lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } 34 | activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } 35 | compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } 36 | androidx-compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "compose-animation" } 37 | ui = { group = "androidx.compose.ui", name = "ui" } 38 | ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } 39 | ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } 40 | material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } 41 | androidx-navigation-runtime-ktx = { group = "androidx.navigation", name = "navigation-runtime-ktx", version.ref = "navigation-runtime-ktx" } 42 | androidx-preference = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference" } 43 | ktor-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } 44 | ktor-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" } 45 | datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } 46 | taglib = { group = "com.github.Kyant0", name = "taglib", version.ref = "taglib" } 47 | kotlin-onetimepassword = { group = "dev.turingcomplete", name = "kotlin-onetimepassword", version.ref = "kotlin-onetimepassword" } 48 | 49 | [plugins] 50 | androidApplication = { id = "com.android.application", version.ref = "agp" } 51 | kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 52 | serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 53 | parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } 54 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 55 | 56 | [bundles] 57 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Apr 28 09:48:17 IRST 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%\bin\java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | @rem Execute Gradle 73 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 74 | 75 | :end 76 | @rem End local scope for the variables with windows NT shell 77 | if "%ERRORLEVEL%"=="0" goto mainEnd 78 | 79 | :fail 80 | @rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 81 | @rem the _cmd.exe /c_ return code! 82 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 83 | exit /b 1 84 | 85 | :mainEnd 86 | if "%OS%"=="Windows_NT" endlocal 87 | 88 | :omega 89 | -------------------------------------------------------------------------------- /screenshots/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/screenshots/screenshot1.png -------------------------------------------------------------------------------- /screenshots/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/screenshots/screenshot2.png -------------------------------------------------------------------------------- /screenshots/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/screenshots/screenshot3.png -------------------------------------------------------------------------------- /screenshots/screenshot4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/screenshots/screenshot4.png -------------------------------------------------------------------------------- /screenshots/screenshot5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lambada10/SongSync/e1dbdb9d1cddc08d059f568f92bcbc4622048604/screenshots/screenshot5.png -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | maven("https://jitpack.io") 7 | } 8 | } 9 | dependencyResolutionManagement { 10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 11 | repositories { 12 | google() 13 | mavenCentral() 14 | maven("https://jitpack.io") 15 | } 16 | } 17 | 18 | rootProject.name = "SongSync" 19 | include(":app") --------------------------------------------------------------------------------