diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..fdfa6e75d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: zionhuang +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: ['https://www.buymeacoffee.com/zionhuang'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c63a41354..074e8271e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -67,11 +67,11 @@ body: If your bug includes a crash, please use `adb logcat` or other ways to provide logs. - type: input - id: music-version + id: app-version attributes: - label: Music version + label: InnerTune version description: | - You can find your Music version in **Settings**. + You can find your InnerTune version in **Settings**. placeholder: | Example: "0.2.1-beta" validations: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3ba13e0ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 6582fa5e4..7e0fa1c25 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature request -description: Suggest an idea for Music +description: Suggest an idea for InnerTune labels: [enhancement] body: - type: checkboxes diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ec1849c5a..901114823 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,7 +8,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Decode Keystore id: decode_keystore @@ -18,7 +18,7 @@ jobs: encodedString: ${{ secrets.KEYSTORE }} - name: set up JDK 11 - uses: actions/setup-java@v2 + uses: actions/setup-java@v3 with: java-version: 11 distribution: "temurin" diff --git a/.github/workflows/build_pr.yml b/.github/workflows/build_pr.yml index 91bb8cb5c..2857a57a0 100644 --- a/.github/workflows/build_pr.yml +++ b/.github/workflows/build_pr.yml @@ -8,10 +8,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: set up JDK 11 - uses: actions/setup-java@v2 + uses: actions/setup-java@v3 with: java-version: 11 distribution: "temurin" diff --git a/README.md b/README.md index ad1124f23..f40518fe7 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ # InnerTune - + -Make your own music library with any song from YouTube Music. -No ads, free, and simple. +A Material 3 YouTube Music client for Android [Get it on F-Droid](https://f-droid.org/packages/com.zionhuang.music) [](https://apt.izzysoft.de/fdroid/index/apk/com.zionhuang.music) @@ -12,114 +11,68 @@ No ads, free, and simple. [![License](https://img.shields.io/github/license/z-huang/InnerTune)](https://www.gnu.org/licenses/gpl-3.0) [![Downloads](https://img.shields.io/github/downloads/z-huang/InnerTune/total)](https://github.com/z-huang/InnerTune/releases) -> **Note** -> -> **1.** The project is currently in an unstable stage, so there should be many bugs. If you encounter one, please report by opening an issue. -> -> **2.** The icon of this app is temporary. It will be changed in the future. - -With this app, you're like getting a free music streaming service. You can listen to music from YouTube Music and build your own library. What's more, songs can be downloaded for offline playback. You can also create playlists to organize your songs. The aim of _InnerTune_ is to enable everyone to listen to music at no cost by an easy-to-use, practical and ad-free application. - -> **Warning** -> ->If you're in region that YouTube Music is not supported, you won't be able to use this app ***unless*** you have proxy or VPN to connect to a YTM supported region. - ## Features -### YouTube - -- Play songs without ads -- Browse almost any YouTube Music page -- Search songs, albums, videos and playlists from YouTube Music -- Open YouTube Music links - -### Library - -- Save songs, albums and playlists in local database -- Download music for offline playback -- Like songs -- local playlist management -- Add links to your favorite YouTube Music playlists -- Export downloaded songs via SAF - -### Player - -- Material design player -- Lockscreen playback -- Cache songs -- (Synchronized) lyrics +- Play songs from YT/YT Music without ads +- Background playback +- Search songs, videos, albums, and playlists from YouTube Music +- Library management +- Cache and download songs for offline playback +- Synchronized lyrics - Skip silence - Audio normalization -- Stat for nerds -- Persistent queue - -### Other - -- Custom themes -- Dark theme +- Dynamic theme - Localization -- Proxy -- Backup & restore -- Support Android Auto +- Android Auto support +- Personalized quick picks +- Material 3 ## Screenshots

- - - - + + +

- - - - + +

-## Installation - -You can install _InnerTune_ using the following methods: - -1. Download the APK file from [GitHub Releases](https://github.com/z-huang/InnerTune/releases). -2. Add [IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/com.zionhuang.music) to your F-Droid repos following the [instruction](https://apt.izzysoft.de/fdroid/index/info), and you can search for this app and receive updates. -3. If you want to have bug fixes or enjoy new features quickly, install debug version from [GitHub Action](https://github.com/z-huang/InnerTune/actions) at your own risk and remember to backup your data more frequently. -4. Clone this repository and build a debug APK. - -How to get updates? - -1. F-Droid application. -2. [GitHub](https://github.com/z-huang/InnerTune) + [Obtainium (beta)](https://github.com/ImranR98/Obtainium) +> **Warning** +> +>If you're in region that YouTube Music is not supported, you won't be able to use this app +***unless*** you have proxy or VPN to connect to a YTM supported region. ## FAQ ### Q: How to scrobble music to LastFM, LibreFM, ListenBrainz or GNU FM? -Use other music scrobbler apps. I recommend [Pano Scrobbler](https://play.google.com/store/apps/details?id=com.arn.scrobble). - -### Q: How to export downloaded song files? - -*InnerTune* supports SAF. You can find the provider in Android native file manager. You can also use [Material Files](https://play.google.com/store/apps/details?id=me.zhanghai.android.files) with [instruction](https://github.com/z-huang/InnerTune/issues/117#issuecomment-1295090708) (recommended). +Use other music scrobbler apps. I +recommend [Pano Scrobbler](https://play.google.com/store/apps/details?id=com.arn.scrobble). ### Q: Why InnerTune isn't showing in Android Auto? -1. Go to Android Auto's settings and tap multiple times on the version in the bottom to enable developer settings +1. Go to Android Auto's settings and tap multiple times on the version in the bottom to enable + developer settings 2. In the three dots menu at the top-right of the screen, click "Developer settings" 3. Enable "Unknown sources" -## Contribution +## Contributing Translations + +Follow the [instruction](https://developer.android.com/guide/topics/resources/localization) and +create a pull request. If possible, please build the app beforehand and make sure there is no error +before you create a pull request. -### Contributing Translations +## Donate -#### App +If you like InnerTune, you're welcome to send a donation. -1. Have a fork of this project. -2. If you have Android Studio, right click on the `app/src/main/res/values` folder, select "New"->"Values Resource File". Input `strings.xml` as file name. Select "Locale", click ">>", choose your language and region, and click "OK". -3. If not, create a folder named `values--r` under `app/src/main/res`. Copy `app/src/main/res/values/strings.xml` to the created folder. -4. Replace each English string with the equivalent translation. Note that lines with `translatable="false"` should be ignored. -5. (Recommended) Build the app to see if something is wrong. -6. Make a pull request with your changes. If you do step 5, the process of accepting your PR will be faster. +Liberapay +Liberapay -#### Fastlane (App Description and Changelogs) +## Credit -Follow the [fastlane instruction](https://gitlab.com/-/snippets/1895688) to add your language and create a pull request. +I want to give credit to [vfsfitvnm/ViMusic](https://github.com/vfsfitvnm/ViMusic) for being an +example of Jetpack Compose music player. It helped me a lot on my way to learn Compose and +Android development. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3248af4c7..eea305491 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,41 +1,35 @@ +@file:Suppress("UnstableApiUsage") + plugins { id("com.android.application") - id("kotlin-android") - id("kotlin-parcelize") - id("kotlin-kapt") - id("androidx.navigation.safeargs") - id("kotlinx-serialization") - id("dev.rikka.tools.materialthemebuilder") + kotlin("android") + kotlin("kapt") + id("com.google.dagger.hilt.android") + id("com.google.devtools.ksp") } android { - compileSdk = 32 + namespace = "com.zionhuang.music" + compileSdk = 33 buildToolsVersion = "30.0.3" defaultConfig { applicationId = "com.zionhuang.music" minSdk = 24 - targetSdk = 32 - versionCode = 15 - versionName = "0.4.4" + targetSdk = 33 + versionCode = 16 + versionName = "0.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - javaCompileOptions { - annotationProcessorOptions { - arguments += mapOf("room.schemaLocation" to "$projectDir/schemas") - } - } - } - applicationVariants.all { - resValue("string", "app_version", versionName) } buildTypes { - getByName("release") { + release { isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + resValue("string", "app_name", "InnerTune") } - getByName("debug") { - isMinifyEnabled = false + debug { applicationIdSuffix = ".debug" + resValue("string", "app_name", "InnerTune Debug") } } signingConfigs { @@ -52,142 +46,83 @@ android { } } buildFeatures { - viewBinding = true - dataBinding = true + compose = true } - packagingOptions { - resources { - excludes += listOf("META-INF/proguard/androidx-annotations.pro", "META-INF/DEPENDENCIES") - } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } compileOptions { isCoreLibraryDesugaringEnabled = true - sourceCompatibility(JavaVersion.VERSION_1_8) - targetCompatibility(JavaVersion.VERSION_1_8) + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "1.8" - freeCompilerArgs = freeCompilerArgs + listOf("-opt-in=kotlin.RequiresOptIn") + kotlin { + jvmToolchain(11) } - configurations.all { - resolutionStrategy { - exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-debug") - } + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" + jvmTarget = "11" } + testOptions { unitTests.isIncludeAndroidResources = true unitTests.isReturnDefaultValues = true } - sourceSets { - // Adds exported schema location as test app assets. - getByName("androidTest").assets.srcDir("$projectDir/schemas") - } } -materialThemeBuilder { - themes { - for ((name, color) in listOf( - "Red" to "F44336", - "Pink" to "E91E63", - "Purple" to "9C27B0", - "DeepPurple" to "673AB7", - "Indigo" to "3F51B5", - "Blue" to "2196F3", - "LightBlue" to "03A9F4", - "Cyan" to "00BCD4", - "Teal" to "009688", - "Green" to "4FAF50", - "LightGreen" to "8BC3A4", - "Lime" to "CDDC39", - "Yellow" to "FFEB3B", - "Amber" to "FFC107", - "Orange" to "FF9800", - "DeepOrange" to "FF5722", - "Brown" to "795548", - "BlueGrey" to "607D8F", - "Sakura" to "FF9CA8" - )) { - create("Material$name") { - lightThemeFormat = "ThemeOverlay.Light.%s" - lightThemeParent = "AppTheme" - darkThemeFormat = "ThemeOverlay.Dark.%s" - darkThemeParent = "AppTheme" - primaryColor = "#$color" - } - } - } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") } dependencies { - // Kotlin - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0") - // AndroidX - implementation("androidx.core:core-ktx:1.8.0") - implementation("androidx.appcompat:appcompat:1.5.1") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.fragment:fragment-ktx:1.5.2") - implementation("androidx.preference:preference-ktx:1.2.0") - implementation("androidx.vectordrawable:vectordrawable:1.1.0") - implementation("androidx.navigation:navigation-runtime-ktx:2.5.2") - implementation("androidx.navigation:navigation-fragment-ktx:2.5.2") - implementation("androidx.navigation:navigation-ui-ktx:2.5.2") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") - implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1") - implementation("androidx.lifecycle:lifecycle-common-java8:2.5.1") - implementation("androidx.legacy:legacy-support-v4:1.0.0") - implementation("androidx.work:work-runtime-ktx:2.7.1") - implementation("androidx.recyclerview:recyclerview-selection:1.1.0") - implementation("androidx.transition:transition-ktx:1.4.1") - implementation("com.google.android.material:material:1.8.0-alpha01") - implementation("dev.chrisbanes.insetter:insetter:0.6.1") - implementation("com.github.bosphere.android-fadingedgelayout:fadingedgelayout:1.0.0") - // Gson - implementation("com.google.code.gson:gson:2.9.0") - // ExoPlayer - implementation("com.google.android.exoplayer:exoplayer:2.18.1") - implementation("com.google.android.exoplayer:extension-mediasession:2.18.1") - implementation("com.google.android.exoplayer:extension-okhttp:2.18.1") - // Paging - implementation("androidx.paging:paging-runtime-ktx:3.1.1") - implementation("androidx.test:monitor:1.5.0") - testImplementation("androidx.paging:paging-common-ktx:3.1.1") - implementation("androidx.paging:paging-rxjava3:3.1.1") - // Room - implementation("androidx.room:room-runtime:2.4.3") - kapt("androidx.room:room-compiler:2.4.3") - implementation("androidx.room:room-rxjava3:2.4.3") - implementation("androidx.room:room-ktx:2.4.3") - implementation("androidx.room:room-paging:2.4.3") - testImplementation("androidx.room:room-testing:2.4.3") - // YouTube API - implementation(project(mapOf("path" to ":innertube"))) - // KuGou - implementation(project(mapOf("path" to ":kugou"))) - // Apache Utils - implementation("org.apache.commons:commons-lang3:3.12.0") - implementation("org.apache.commons:commons-text:1.9") - // OkHttp - implementation("com.squareup.okhttp3:okhttp:4.10.0") - // Coil - implementation("io.coil-kt:coil:2.2.1") - // Fast Scroll - implementation("me.zhanghai.android.fastscroll:library:1.1.8") - // Markdown - implementation("org.commonmark:commonmark:0.18.2") - // Desugaring - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5") - // Test - testImplementation("junit:junit:4.13.2") - androidTestImplementation("android.arch.core:core-testing:1.1.1") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test:runner:1.4.0") - androidTestImplementation("androidx.test:rules:1.4.0") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") - testImplementation("org.mockito:mockito-core:4.8.0") - testImplementation("org.mockito:mockito-inline:4.3.1") - testImplementation("org.mockito:mockito-android:4.3.1") - testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0") + implementation(libs.guava) + implementation(libs.coroutines.guava) + implementation(libs.concurrent.futures) + + implementation(libs.activity) + implementation(libs.navigation) + implementation(libs.hilt.navigation) + implementation(libs.datastore) + + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.ui.util) + implementation(libs.compose.ui.tooling) + implementation(libs.compose.animation) + implementation(libs.compose.animation.graphics) + + implementation(libs.viewmodel) + implementation(libs.viewmodel.compose) + + implementation(libs.material3) + implementation(libs.palette) + implementation(projects.materialColorUtilities) + + implementation(libs.accompanist.swiperefresh) + + implementation(libs.coil) + + implementation(libs.shimmer) + + implementation(libs.media3) + implementation(libs.media3.session) + implementation(libs.media3.okhttp) + + implementation(libs.room.runtime) + annotationProcessor(libs.room.compiler) + ksp(libs.room.compiler) + implementation(libs.room.ktx) + + implementation(libs.apache.lang3) + + implementation(libs.hilt) + kapt(libs.hilt.compiler) + + implementation(projects.innertube) + implementation(projects.kugou) + + coreLibraryDesugaring(libs.desugaring) + + implementation(libs.timber) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 1aa730e53..570614915 100755 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -20,38 +20,6 @@ # hide the original source file name. #-renamesourcefileattribute SourceFile -##---------------Begin: proguard configuration for Gson ---------- -# Gson uses generic type information stored in a class file when working with fields. Proguard -# removes such information by default, so configure it to keep all of it. --keepattributes Signature - -# For using GSON @Expose annotation --keepattributes *Annotation* - -# Gson specific classes --dontwarn sun.misc.** -#-keep class com.google.gson.stream.** { *; } - -# Application classes that will be serialized/deserialized over Gson --keep class com.google.gson.examples.android.model.** { ; } - -# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, -# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) --keep class * extends com.google.gson.TypeAdapter --keep class * implements com.google.gson.TypeAdapterFactory --keep class * implements com.google.gson.JsonSerializer --keep class * implements com.google.gson.JsonDeserializer - -# Prevent R8 from leaving Data object members always null --keepclassmembers,allowobfuscation class * { - @com.google.gson.annotations.SerializedName ; -} - -# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. --keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken --keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken -##---------------End: proguard configuration for Gson ---------- - ## Kotlin Serialization # Keep `Companion` object fields of serializable classes. # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. diff --git a/app/schemas/com.zionhuang.music.db.MusicDatabase/1.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/1.json similarity index 100% rename from app/schemas/com.zionhuang.music.db.MusicDatabase/1.json rename to app/schemas/com.zionhuang.music.db.InternalDatabase/1.json diff --git a/app/schemas/com.zionhuang.music.db.InternalDatabase/10.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/10.json new file mode 100644 index 000000000..9bcd25dd9 --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.InternalDatabase/10.json @@ -0,0 +1,814 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "465b6d837bb0b1291e375df6f08219cb", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `downloadState` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '465b6d837bb0b1291e375df6f08219cb')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.zionhuang.music.db.MusicDatabase/2.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/2.json similarity index 100% rename from app/schemas/com.zionhuang.music.db.MusicDatabase/2.json rename to app/schemas/com.zionhuang.music.db.InternalDatabase/2.json diff --git a/app/schemas/com.zionhuang.music.db.MusicDatabase/3.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/3.json similarity index 100% rename from app/schemas/com.zionhuang.music.db.MusicDatabase/3.json rename to app/schemas/com.zionhuang.music.db.InternalDatabase/3.json diff --git a/app/schemas/com.zionhuang.music.db.MusicDatabase/4.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/4.json similarity index 100% rename from app/schemas/com.zionhuang.music.db.MusicDatabase/4.json rename to app/schemas/com.zionhuang.music.db.InternalDatabase/4.json diff --git a/app/schemas/com.zionhuang.music.db.InternalDatabase/5.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/5.json new file mode 100644 index 000000000..48533ee45 --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.InternalDatabase/5.json @@ -0,0 +1,748 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "2ab124580a16b74c86883a1a06edae27", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `isTrash` INTEGER NOT NULL, `download_state` INTEGER NOT NULL, `create_date` INTEGER NOT NULL, `modify_date` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "download_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "create_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modify_date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT, `authorId` TEXT, `year` INTEGER, `thumbnailUrl` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "songId", + "artistId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "songId", + "albumId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "albumId", + "artistId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2ab124580a16b74c86883a1a06edae27')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.zionhuang.music.db.InternalDatabase/6.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/6.json new file mode 100644 index 000000000..5ec90c00b --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.InternalDatabase/6.json @@ -0,0 +1,712 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "e099eec2e21e2def3fd2dc8b29798a02", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `downloadState` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `modifyDate` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modifyDate", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e099eec2e21e2def3fd2dc8b29798a02')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.zionhuang.music.db.InternalDatabase/7.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/7.json new file mode 100644 index 000000000..2924f6582 --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.InternalDatabase/7.json @@ -0,0 +1,718 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "8badff35bb8509366509650a5b15634a", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `downloadState` INTEGER NOT NULL, `inLibrary` INTEGER, `createDate` INTEGER NOT NULL, `modifyDate` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modifyDate", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8badff35bb8509366509650a5b15634a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.zionhuang.music.db.InternalDatabase/8.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/8.json new file mode 100644 index 000000000..04e7f3af3 --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.InternalDatabase/8.json @@ -0,0 +1,766 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "8de04c586d6be08319c8fab4240706ff", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `downloadState` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8de04c586d6be08319c8fab4240706ff')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.zionhuang.music.db.InternalDatabase/9.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/9.json new file mode 100644 index 000000000..4426115f5 --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.InternalDatabase/9.json @@ -0,0 +1,840 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "ccad10efd9b5c5ee1dc9b42c6e3715fd", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `downloadState` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ccad10efd9b5c5ee1dc9b42c6e3715fd')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/zionhuang/music/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/zionhuang/music/ExampleInstrumentedTest.java deleted file mode 100755 index 7577f8939..000000000 --- a/app/src/androidTest/java/com/zionhuang/music/ExampleInstrumentedTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.zionhuang.music; - -import android.content.Context; - -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.*; - -/** - * Instrumented test, which will execute on an Android device. - * - * @see Testing documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - @Test - public void useAppContext() { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); - - assertEquals("com.zionhuang.music", appContext.getPackageName()); - } -} diff --git a/app/src/debug/res/values/constants.xml b/app/src/debug/res/values/constants.xml deleted file mode 100644 index e081595ea..000000000 --- a/app/src/debug/res/values/constants.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - InnerTune Debug - \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6cfac45b0..302df9b66 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,12 +1,13 @@ + xmlns:tools="http://schemas.android.com/tools"> + + - + @@ -21,17 +22,18 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/AppTheme" + android:theme="@style/Theme.InnerTune" tools:targetApi="o"> + android:theme="@style/Theme.InnerTune" + android:windowSoftInputMode="adjustResize"> @@ -104,14 +106,6 @@ - - - - + + - - - - - - - - - - - - - - - + - - + + - + The {@link SeekBar} within the preference can be defined adjustable or not by setting {@code - * adjustable} attribute. If adjustable, the preference will be responsive to DPAD left/right keys. - * Otherwise, it skips those keys. - * - *

The {@link SeekBar} value view can be shown or disabled by setting {@code showSeekBarValue} - * attribute to true or false, respectively. - * - *

Other {@link SeekBar} specific attributes (e.g. {@code title, summary, defaultValue, min, - * max}) - * can be set directly on the preference widget layout. - */ -public class NeoSeekBarPreference extends Preference { - - private static final String TAG = "SeekBarPreference"; - @SuppressWarnings("WeakerAccess") /* synthetic access */ - int mSeekBarValue; - @SuppressWarnings("WeakerAccess") /* synthetic access */ - int mMin; - private int mMax; - private int mSeekBarIncrement; - @SuppressWarnings("WeakerAccess") /* synthetic access */ - boolean mTrackingTouch; - @SuppressWarnings("WeakerAccess") /* synthetic access */ - SeekBar mSeekBar; - private TextView mSeekBarValueTextView; - // Whether the SeekBar should respond to the left/right keys - @SuppressWarnings("WeakerAccess") /* synthetic access */ - boolean mAdjustable; - // Whether to show the SeekBar value TextView next to the bar - private boolean mShowSeekBarValue; - // Whether the SeekBarPreference should continuously save the Seekbar value while it is being - // dragged. - @SuppressWarnings("WeakerAccess") /* synthetic access */ - boolean mUpdatesContinuously; - /** - * Listener reacting to the {@link SeekBar} changing value by the user - */ - private final OnSeekBarChangeListener mSeekBarChangeListener = new OnSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - if (fromUser && (mUpdatesContinuously || !mTrackingTouch)) { - syncValueInternal(seekBar); - } else { - // We always want to update the text while the seekbar is being dragged - updateLabelValue(progress + mMin); - } - } - - @Override - public void onStartTrackingTouch(SeekBar seekBar) { - mTrackingTouch = true; - } - - @Override - public void onStopTrackingTouch(SeekBar seekBar) { - mTrackingTouch = false; - if (seekBar.getProgress() + mMin != mSeekBarValue) { - syncValueInternal(seekBar); - } - } - }; - - /** - * Listener reacting to the user pressing DPAD left/right keys if {@code - * adjustable} attribute is set to true; it transfers the key presses to the {@link SeekBar} - * to be handled accordingly. - */ - private final View.OnKeyListener mSeekBarKeyListener = new View.OnKeyListener() { - @Override - public boolean onKey(View v, int keyCode, KeyEvent event) { - if (event.getAction() != KeyEvent.ACTION_DOWN) { - return false; - } - - if (!mAdjustable && (keyCode == KeyEvent.KEYCODE_DPAD_LEFT - || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT)) { - // Right or left keys are pressed when in non-adjustable mode; Skip the keys. - return false; - } - - // We don't want to propagate the click keys down to the SeekBar view since it will - // create the ripple effect for the thumb. - if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { - return false; - } - - if (mSeekBar == null) { - Log.e(TAG, "SeekBar view is null and hence cannot be adjusted."); - return false; - } - return mSeekBar.onKeyDown(keyCode, event); - } - }; - - public NeoSeekBarPreference( - @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, - int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - - TypedArray a = context.obtainStyledAttributes( - attrs, R.styleable.SeekBarPreference, defStyleAttr, defStyleRes); - - // The ordering of these two statements are important. If we want to set max first, we need - // to perform the same steps by changing min/max to max/min as following: - // mMax = a.getInt(...) and setMin(...). - mMin = a.getInt(R.styleable.SeekBarPreference_min, 0); - setMax(a.getInt(R.styleable.SeekBarPreference_android_max, 100)); - setSeekBarIncrement(a.getInt(R.styleable.SeekBarPreference_seekBarIncrement, 0)); - mAdjustable = a.getBoolean(R.styleable.SeekBarPreference_adjustable, true); - mShowSeekBarValue = a.getBoolean(R.styleable.SeekBarPreference_showSeekBarValue, false); - mUpdatesContinuously = a.getBoolean(R.styleable.SeekBarPreference_updatesContinuously, - false); - a.recycle(); - } - - public NeoSeekBarPreference(@NonNull Context context, @Nullable AttributeSet attrs, - int defStyleAttr) { - this(context, attrs, defStyleAttr, 0); - } - - public NeoSeekBarPreference(@NonNull Context context, @Nullable AttributeSet attrs) { - this(context, attrs, R.attr.seekBarPreferenceStyle); - } - - public NeoSeekBarPreference(@NonNull Context context) { - this(context, null); - } - - @Override - public void onBindViewHolder(@NonNull PreferenceViewHolder holder) { - super.onBindViewHolder(holder); - holder.itemView.setOnKeyListener(mSeekBarKeyListener); - mSeekBar = (SeekBar) holder.findViewById(R.id.seekbar); - mSeekBarValueTextView = (TextView) holder.findViewById(R.id.seekbar_value); - if (mShowSeekBarValue) { - mSeekBarValueTextView.setVisibility(View.VISIBLE); - } else { - mSeekBarValueTextView.setVisibility(View.GONE); - mSeekBarValueTextView = null; - } - - if (mSeekBar == null) { - Log.e(TAG, "SeekBar view is null in onBindViewHolder."); - return; - } - mSeekBar.setOnSeekBarChangeListener(mSeekBarChangeListener); - mSeekBar.setMax(mMax - mMin); - // If the increment is not zero, use that. Otherwise, use the default mKeyProgressIncrement - // in AbsSeekBar when it's zero. This default increment value is set by AbsSeekBar - // after calling setMax. That's why it's important to call setKeyProgressIncrement after - // calling setMax() since setMax() can change the increment value. - if (mSeekBarIncrement != 0) { - mSeekBar.setKeyProgressIncrement(mSeekBarIncrement); - } else { - mSeekBarIncrement = mSeekBar.getKeyProgressIncrement(); - } - - mSeekBar.setProgress(mSeekBarValue - mMin); - updateLabelValue(mSeekBarValue); - mSeekBar.setEnabled(isEnabled()); - } - - @Override - protected void onSetInitialValue(Object defaultValue) { - if (defaultValue == null) { - defaultValue = 0; - } - setValue(getPersistedInt((Integer) defaultValue)); - } - - @Override - protected @Nullable - Object onGetDefaultValue(@NonNull TypedArray a, int index) { - return a.getInt(index, 0); - } - - /** - * Gets the lower bound set on the {@link SeekBar}. - * - * @return The lower bound set - */ - public int getMin() { - return mMin; - } - - /** - * Sets the lower bound on the {@link SeekBar}. - * - * @param min The lower bound to set - */ - public void setMin(int min) { - if (min > mMax) { - min = mMax; - } - if (min != mMin) { - mMin = min; - notifyChanged(); - } - } - - /** - * Returns the amount of increment change via each arrow key click. This value is derived from - * user's specified increment value if it's not zero. Otherwise, the default value is picked - * from the default mKeyProgressIncrement value in {@link android.widget.AbsSeekBar}. - * - * @return The amount of increment on the {@link SeekBar} performed after each user's arrow - * key press - */ - public final int getSeekBarIncrement() { - return mSeekBarIncrement; - } - - /** - * Sets the increment amount on the {@link SeekBar} for each arrow key press. - * - * @param seekBarIncrement The amount to increment or decrement when the user presses an - * arrow key. - */ - public final void setSeekBarIncrement(int seekBarIncrement) { - if (seekBarIncrement != mSeekBarIncrement) { - mSeekBarIncrement = Math.min(mMax - mMin, Math.abs(seekBarIncrement)); - notifyChanged(); - } - } - - /** - * Gets the upper bound set on the {@link SeekBar}. - * - * @return The upper bound set - */ - public int getMax() { - return mMax; - } - - /** - * Sets the upper bound on the {@link SeekBar}. - * - * @param max The upper bound to set - */ - public final void setMax(int max) { - if (max < mMin) { - max = mMin; - } - if (max != mMax) { - mMax = max; - notifyChanged(); - } - } - - /** - * Gets whether the {@link SeekBar} should respond to the left/right keys. - * - * @return Whether the {@link SeekBar} should respond to the left/right keys - */ - public boolean isAdjustable() { - return mAdjustable; - } - - /** - * Sets whether the {@link SeekBar} should respond to the left/right keys. - * - * @param adjustable Whether the {@link SeekBar} should respond to the left/right keys - */ - public void setAdjustable(boolean adjustable) { - mAdjustable = adjustable; - } - - /** - * Gets whether the {@link NeoSeekBarPreference} should continuously save the {@link SeekBar} value - * while it is being dragged. Note that when the value is true, - * {@link Preference.OnPreferenceChangeListener} will be called continuously as well. - * - * @return Whether the {@link NeoSeekBarPreference} should continuously save the {@link SeekBar} - * value while it is being dragged - * @see #setUpdatesContinuously(boolean) - */ - public boolean getUpdatesContinuously() { - return mUpdatesContinuously; - } - - /** - * Sets whether the {@link NeoSeekBarPreference} should continuously save the {@link SeekBar} value - * while it is being dragged. - * - * @param updatesContinuously Whether the {@link NeoSeekBarPreference} should continuously save - * the {@link SeekBar} value while it is being dragged - * @see #getUpdatesContinuously() - */ - public void setUpdatesContinuously(boolean updatesContinuously) { - mUpdatesContinuously = updatesContinuously; - } - - /** - * Gets whether the current {@link SeekBar} value is displayed to the user. - * - * @return Whether the current {@link SeekBar} value is displayed to the user - * @see #setShowSeekBarValue(boolean) - */ - public boolean getShowSeekBarValue() { - return mShowSeekBarValue; - } - - /** - * Sets whether the current {@link SeekBar} value is displayed to the user. - * - * @param showSeekBarValue Whether the current {@link SeekBar} value is displayed to the user - * @see #getShowSeekBarValue() - */ - public void setShowSeekBarValue(boolean showSeekBarValue) { - mShowSeekBarValue = showSeekBarValue; - notifyChanged(); - } - - private void setValueInternal(int seekBarValue, boolean notifyChanged) { - if (seekBarValue < mMin) { - seekBarValue = mMin; - } - if (seekBarValue > mMax) { - seekBarValue = mMax; - } - - if (seekBarValue != mSeekBarValue) { - mSeekBarValue = seekBarValue; - updateLabelValue(mSeekBarValue); - persistInt(seekBarValue); - if (notifyChanged) { - notifyChanged(); - } - } - } - - /** - * Gets the current progress of the {@link SeekBar}. - * - * @return The current progress of the {@link SeekBar} - */ - public int getValue() { - return mSeekBarValue; - } - - /** - * Sets the current progress of the {@link SeekBar}. - * - * @param seekBarValue The current progress of the {@link SeekBar} - */ - public void setValue(int seekBarValue) { - setValueInternal(seekBarValue, true); - } - - /** - * Persist the {@link SeekBar}'s SeekBar value if callChangeListener returns true, otherwise - * set the {@link SeekBar}'s value to the stored value. - */ - @SuppressWarnings("WeakerAccess") /* synthetic access */ - void syncValueInternal(@NonNull SeekBar seekBar) { - int seekBarValue = mMin + seekBar.getProgress(); - if (seekBarValue != mSeekBarValue) { - if (callChangeListener(seekBarValue)) { - setValueInternal(seekBarValue, false); - } else { - seekBar.setProgress(mSeekBarValue - mMin); - updateLabelValue(mSeekBarValue); - } - } - } - - // MODIFIED: Custom label formatter - private Function labelFormatter = String::valueOf; - - public void setLabelFormatter(Function formatter) { - labelFormatter = formatter; - } - - /** - * Attempts to update the TextView label that displays the current value. - * - * @param value the value to display next to the {@link SeekBar} - */ - @SuppressWarnings("WeakerAccess") /* synthetic access */ - void updateLabelValue(int value) { - if (mSeekBarValueTextView != null) { - mSeekBarValueTextView.setText(labelFormatter.apply(value)); - } - } - - @Nullable - @Override - protected Parcelable onSaveInstanceState() { - final Parcelable superState = super.onSaveInstanceState(); - if (isPersistent()) { - // No need to save instance state since it's persistent - return superState; - } - - // Save the instance state - final SavedState myState = new SavedState(superState); - myState.mSeekBarValue = mSeekBarValue; - myState.mMin = mMin; - myState.mMax = mMax; - return myState; - } - - @Override - protected void onRestoreInstanceState(@Nullable Parcelable state) { - if (state == null || !state.getClass().equals(SavedState.class)) { - // Didn't save state for us in onSaveInstanceState - super.onRestoreInstanceState(state); - return; - } - - // Restore the instance state - SavedState myState = (SavedState) state; - super.onRestoreInstanceState(myState.getSuperState()); - mSeekBarValue = myState.mSeekBarValue; - mMin = myState.mMin; - mMax = myState.mMax; - notifyChanged(); - } - - /** - * SavedState, a subclass of {@link BaseSavedState}, will store the state of this preference. - * - *

It is important to always call through to super methods. - */ - private static class SavedState extends BaseSavedState { - public static final Parcelable.Creator CREATOR = - new Parcelable.Creator() { - @Override - public SavedState createFromParcel(Parcel in) { - return new SavedState(in); - } - - @Override - public SavedState[] newArray(int size) { - return new SavedState[size]; - } - }; - - int mSeekBarValue; - int mMin; - int mMax; - - SavedState(Parcel source) { - super(source); - - // Restore the click counter - mSeekBarValue = source.readInt(); - mMin = source.readInt(); - mMax = source.readInt(); - } - - SavedState(Parcelable superState) { - super(superState); - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - super.writeToParcel(dest, flags); - - // Save the click counter - dest.writeInt(mSeekBarValue); - dest.writeInt(mMin); - dest.writeInt(mMax); - } - } -} diff --git a/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java deleted file mode 100644 index 39c0d54a5..000000000 --- a/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java +++ /dev/null @@ -1,2210 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.material.bottomsheet; - -import com.google.android.material.R; - -import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; -import static java.lang.Math.max; -import static java.lang.Math.min; - -import android.animation.ValueAnimator; -import android.animation.ValueAnimator.AnimatorUpdateListener; -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.res.ColorStateList; -import android.content.res.TypedArray; -import android.os.Build; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; -import android.os.Parcel; -import android.os.Parcelable; -import android.util.AttributeSet; -import android.util.Log; -import android.util.TypedValue; -import android.view.MotionEvent; -import android.view.VelocityTracker; -import android.view.View; -import android.view.View.MeasureSpec; -import android.view.ViewConfiguration; -import android.view.ViewGroup; -import android.view.ViewGroup.MarginLayoutParams; -import android.view.ViewParent; -import android.view.accessibility.AccessibilityEvent; -import androidx.annotation.FloatRange; -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.Px; -import androidx.annotation.RestrictTo; -import androidx.annotation.StringRes; -import androidx.annotation.VisibleForTesting; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams; -import androidx.core.graphics.Insets; -import androidx.core.math.MathUtils; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; -import androidx.core.view.accessibility.AccessibilityViewCommand; -import androidx.customview.view.AbsSavedState; -import androidx.customview.widget.ViewDragHelper; -import com.google.android.material.internal.ViewUtils; -import com.google.android.material.internal.ViewUtils.RelativePadding; -import com.google.android.material.resources.MaterialResources; -import com.google.android.material.shape.MaterialShapeDrawable; -import com.google.android.material.shape.ShapeAppearanceModel; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; - -/** - * An interaction behavior plugin for a child view of {@link CoordinatorLayout} to make it work as a - * bottom sheet. - * - *

To send useful accessibility events, set a title on bottom sheets that are windows or are - * window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for - * BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}. - */ -public class NeoBottomSheetBehavior extends CoordinatorLayout.Behavior { - - /** Callback for monitoring events about bottom sheets. */ - public abstract static class BottomSheetCallback { - - /** - * Called when the bottom sheet changes its state. - * - * @param bottomSheet The bottom sheet view. - * @param newState The new state. This will be one of {@link #STATE_DRAGGING}, {@link - * #STATE_SETTLING}, {@link #STATE_EXPANDED}, {@link #STATE_COLLAPSED}, {@link - * #STATE_HIDDEN}, or {@link #STATE_HALF_EXPANDED}. - */ - public abstract void onStateChanged(@NonNull View bottomSheet, @State int newState); - - /** - * Called when the bottom sheet is being dragged. - * - * @param bottomSheet The bottom sheet view. - * @param slideOffset The new offset of this bottom sheet within [-1,1] range. Offset increases - * as this bottom sheet is moving upward. From 0 to 1 the sheet is between collapsed and - * expanded states and from -1 to 0 it is between hidden and collapsed states. - */ - public abstract void onSlide(@NonNull View bottomSheet, float slideOffset); - - void onLayout(@NonNull View bottomSheet) {} - } - - /** The bottom sheet is dragging. */ - public static final int STATE_DRAGGING = 1; - - /** The bottom sheet is settling. */ - public static final int STATE_SETTLING = 2; - - /** The bottom sheet is expanded. */ - public static final int STATE_EXPANDED = 3; - - /** The bottom sheet is collapsed. */ - public static final int STATE_COLLAPSED = 4; - - /** The bottom sheet is hidden. */ - public static final int STATE_HIDDEN = 5; - - /** The bottom sheet is half-expanded (used when fitToContents is false). */ - public static final int STATE_HALF_EXPANDED = 6; - - /** @hide */ - @RestrictTo(LIBRARY_GROUP) - @IntDef({ - STATE_EXPANDED, - STATE_COLLAPSED, - STATE_DRAGGING, - STATE_SETTLING, - STATE_HIDDEN, - STATE_HALF_EXPANDED - }) - @Retention(RetentionPolicy.SOURCE) - public @interface State {} - - /** - * Stable states that can be set by the {@link #setState(int)} method. These includes all the - * possible states a bottom sheet can be in when it's settled. - * - * @hide - */ - @RestrictTo(LIBRARY_GROUP) - @IntDef({STATE_EXPANDED, STATE_COLLAPSED, STATE_HIDDEN, STATE_HALF_EXPANDED}) - @Retention(RetentionPolicy.SOURCE) - public @interface StableState {} - - /** - * Peek at the 16:9 ratio keyline of its parent. - * - *

This can be used as a parameter for {@link #setPeekHeight(int)}. {@link #getPeekHeight()} - * will return this when the value is set. - */ - public static final int PEEK_HEIGHT_AUTO = -1; - - /** This flag will preserve the peekHeight int value on configuration change. */ - public static final int SAVE_PEEK_HEIGHT = 0x1; - - /** This flag will preserve the fitToContents boolean value on configuration change. */ - public static final int SAVE_FIT_TO_CONTENTS = 1 << 1; - - /** This flag will preserve the hideable boolean value on configuration change. */ - public static final int SAVE_HIDEABLE = 1 << 2; - - /** This flag will preserve the skipCollapsed boolean value on configuration change. */ - public static final int SAVE_SKIP_COLLAPSED = 1 << 3; - - /** This flag will preserve all aforementioned values on configuration change. */ - public static final int SAVE_ALL = -1; - - /** - * This flag will not preserve the aforementioned values set at runtime if the view is destroyed - * and recreated. The only value preserved will be the positional state, e.g. collapsed, hidden, - * expanded, etc. This is the default behavior. - */ - public static final int SAVE_NONE = 0; - - /** @hide */ - @RestrictTo(LIBRARY_GROUP) - @IntDef( - flag = true, - value = { - SAVE_PEEK_HEIGHT, - SAVE_FIT_TO_CONTENTS, - SAVE_HIDEABLE, - SAVE_SKIP_COLLAPSED, - SAVE_ALL, - SAVE_NONE, - }) - @Retention(RetentionPolicy.SOURCE) - public @interface SaveFlags {} - - private static final String TAG = "NeoBottomSheetBehavior"; - - @SaveFlags private int saveFlags = SAVE_NONE; - - @VisibleForTesting static final int DEFAULT_SIGNIFICANT_VEL_THRESHOLD = 500; - - private static final float HIDE_THRESHOLD = 0.5f; - - private static final float HIDE_FRICTION = 0.1f; - - private static final int CORNER_ANIMATION_DURATION = 500; - - private static final int NO_MAX_SIZE = -1; - - private boolean fitToContents = true; - - private boolean updateImportantForAccessibilityOnSiblings = false; - - private float maximumVelocity; - - private int significantVelocityThreshold; - - /** Peek height set by the user. */ - private int peekHeight; - - /** Whether or not to use automatic peek height. */ - private boolean peekHeightAuto; - - /** Minimum peek height permitted. */ - private int peekHeightMin; - - /** Peek height gesture inset buffer to ensure enough swipeable space. */ - private int peekHeightGestureInsetBuffer; - - private MaterialShapeDrawable materialShapeDrawable; - - @Nullable private ColorStateList backgroundTint; - - private int maxWidth = NO_MAX_SIZE; - - private int maxHeight = NO_MAX_SIZE; - - private int gestureInsetBottom; - private boolean gestureInsetBottomIgnored; - private boolean paddingBottomSystemWindowInsets; - private boolean paddingLeftSystemWindowInsets; - private boolean paddingRightSystemWindowInsets; - private boolean paddingTopSystemWindowInsets; - private boolean marginLeftSystemWindowInsets; - private boolean marginRightSystemWindowInsets; - private boolean marginTopSystemWindowInsets; - - private int insetBottom; - private int insetTop; - - /** Default Shape Appearance to be used in bottomsheet */ - private ShapeAppearanceModel shapeAppearanceModelDefault; - - private boolean isShapeExpanded; - - private final StateSettlingTracker stateSettlingTracker = new StateSettlingTracker(); - - @Nullable private ValueAnimator interpolatorAnimator; - - private static final int DEF_STYLE_RES = R.style.Widget_Design_BottomSheet_Modal; - - int expandedOffset; - - int fitToContentsOffset; - - int halfExpandedOffset; - - float halfExpandedRatio = 0.5f; - - int collapsedOffset; - - float elevation = -1; - - boolean hideable; - - private boolean skipCollapsed; - - private boolean draggable = true; - - @State int state = STATE_COLLAPSED; - - @State int lastStableState = STATE_COLLAPSED; - - @Nullable ViewDragHelper viewDragHelper; - - private boolean ignoreEvents; - - private int lastNestedScrollDy; - - private boolean nestedScrolled; - - private float hideFriction = HIDE_FRICTION; - - private int childHeight; - int parentWidth; - int parentHeight; - - @Nullable WeakReference viewRef; - - @Nullable WeakReference nestedScrollingChildRef; - - @NonNull private final ArrayList callbacks = new ArrayList<>(); - - @Nullable private VelocityTracker velocityTracker; - - int activePointerId; - - private int initialY; - - boolean touchingScrollingChild; - - @Nullable private Map importantForAccessibilityMap; - - private int expandHalfwayActionId = View.NO_ID; - - public NeoBottomSheetBehavior() {} - - @SuppressLint("RestrictedApi") - public NeoBottomSheetBehavior(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - - peekHeightGestureInsetBuffer = - context.getResources().getDimensionPixelSize(R.dimen.mtrl_min_touch_target_size); - - TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.BottomSheetBehavior_Layout); - if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_backgroundTint)) { - this.backgroundTint = MaterialResources.getColorStateList( - context, a, R.styleable.BottomSheetBehavior_Layout_backgroundTint); - } - if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_shapeAppearance)) { - this.shapeAppearanceModelDefault = - ShapeAppearanceModel.builder(context, attrs, R.attr.bottomSheetStyle, DEF_STYLE_RES) - .build(); - } - createMaterialShapeDrawableIfNeeded(context); - createShapeValueAnimator(); - - if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { - this.elevation = a.getDimension(R.styleable.BottomSheetBehavior_Layout_android_elevation, -1); - } - - if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_android_maxWidth)) { - setMaxWidth( - a.getDimensionPixelSize( - R.styleable.BottomSheetBehavior_Layout_android_maxWidth, NO_MAX_SIZE)); - } - - if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_android_maxHeight)) { - setMaxHeight( - a.getDimensionPixelSize( - R.styleable.BottomSheetBehavior_Layout_android_maxHeight, NO_MAX_SIZE)); - } - - TypedValue value = a.peekValue(R.styleable.BottomSheetBehavior_Layout_behavior_peekHeight); - if (value != null && value.data == PEEK_HEIGHT_AUTO) { - setPeekHeight(value.data); - } else { - setPeekHeight( - a.getDimensionPixelSize( - R.styleable.BottomSheetBehavior_Layout_behavior_peekHeight, PEEK_HEIGHT_AUTO)); - } - setHideable(a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_hideable, false)); - setGestureInsetBottomIgnored( - a.getBoolean(R.styleable.BottomSheetBehavior_Layout_gestureInsetBottomIgnored, false)); - setFitToContents( - a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_fitToContents, true)); - setSkipCollapsed( - a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_skipCollapsed, false)); - setDraggable(a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_draggable, true)); - setSaveFlags(a.getInt(R.styleable.BottomSheetBehavior_Layout_behavior_saveFlags, SAVE_NONE)); - setHalfExpandedRatio( - a.getFloat(R.styleable.BottomSheetBehavior_Layout_behavior_halfExpandedRatio, 0.5f)); - - value = a.peekValue(R.styleable.BottomSheetBehavior_Layout_behavior_expandedOffset); - if (value != null && value.type == TypedValue.TYPE_FIRST_INT) { - setExpandedOffset(value.data); - } else { - setExpandedOffset( - a.getDimensionPixelOffset( - R.styleable.BottomSheetBehavior_Layout_behavior_expandedOffset, 0)); - } - - setSignificantVelocityThreshold( - a.getInt( - R.styleable.BottomSheetBehavior_Layout_behavior_significantVelocityThreshold, - DEFAULT_SIGNIFICANT_VEL_THRESHOLD)); - - // Reading out if we are handling padding, so we can apply it to the content. - paddingBottomSystemWindowInsets = - a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingBottomSystemWindowInsets, false); - paddingLeftSystemWindowInsets = - a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingLeftSystemWindowInsets, false); - paddingRightSystemWindowInsets = - a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingRightSystemWindowInsets, false); - // Setting this to false will prevent the bottomsheet from going below the status bar. Since - // this is a breaking change from the old behavior the default is true. - paddingTopSystemWindowInsets = - a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingTopSystemWindowInsets, true); - marginLeftSystemWindowInsets = - a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginLeftSystemWindowInsets, false); - marginRightSystemWindowInsets = - a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginRightSystemWindowInsets, false); - marginTopSystemWindowInsets = - a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginTopSystemWindowInsets, false); - - a.recycle(); - ViewConfiguration configuration = ViewConfiguration.get(context); - maximumVelocity = configuration.getScaledMaximumFlingVelocity(); - } - - @NonNull - @Override - public Parcelable onSaveInstanceState(@NonNull CoordinatorLayout parent, @NonNull V child) { - return new SavedState(super.onSaveInstanceState(parent, child), this); - } - - @Override - public void onRestoreInstanceState( - @NonNull CoordinatorLayout parent, @NonNull V child, @NonNull Parcelable state) { - SavedState ss = (SavedState) state; - super.onRestoreInstanceState(parent, child, ss.getSuperState()); - // Restore Optional State values designated by saveFlags - restoreOptionalState(ss); - // Intermediate states are restored as collapsed state - if (ss.state == STATE_DRAGGING || ss.state == STATE_SETTLING) { - this.state = STATE_COLLAPSED; - this.lastStableState = this.state; - } else { - this.state = ss.state; - this.lastStableState = this.state; - } - } - - @Override - public void onAttachedToLayoutParams(@NonNull LayoutParams layoutParams) { - super.onAttachedToLayoutParams(layoutParams); - // These may already be null, but just be safe, explicitly assign them. This lets us know the - // first time we layout with this behavior by checking (viewRef == null). - viewRef = null; - viewDragHelper = null; - } - - @Override - public void onDetachedFromLayoutParams() { - super.onDetachedFromLayoutParams(); - // Release references so we don't run unnecessary codepaths while not attached to a view. - viewRef = null; - viewDragHelper = null; - } - - @Override - public boolean onMeasureChild( - @NonNull CoordinatorLayout parent, - @NonNull V child, - int parentWidthMeasureSpec, - int widthUsed, - int parentHeightMeasureSpec, - int heightUsed) { - MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); - int childWidthMeasureSpec = - getChildMeasureSpec( - parentWidthMeasureSpec, - parent.getPaddingLeft() - + parent.getPaddingRight() - + lp.leftMargin - + lp.rightMargin - + widthUsed, - maxWidth, - lp.width); - int childHeightMeasureSpec = - getChildMeasureSpec( - parentHeightMeasureSpec, - parent.getPaddingTop() - + parent.getPaddingBottom() - + lp.topMargin - + lp.bottomMargin - + heightUsed, - maxHeight, - lp.height); - child.measure(childWidthMeasureSpec, childHeightMeasureSpec); - return true; // Child was measured - } - - private int getChildMeasureSpec( - int parentMeasureSpec, int padding, int maxSize, int childDimension) { - int result = ViewGroup.getChildMeasureSpec(parentMeasureSpec, padding, childDimension); - if (maxSize == NO_MAX_SIZE) { - return result; - } else { - int mode = MeasureSpec.getMode(result); - int size = MeasureSpec.getSize(result); - switch (mode) { - case MeasureSpec.EXACTLY: - return MeasureSpec.makeMeasureSpec(min(size, maxSize), MeasureSpec.EXACTLY); - case MeasureSpec.AT_MOST: - case MeasureSpec.UNSPECIFIED: - default: - return MeasureSpec.makeMeasureSpec( - size == 0 ? maxSize : min(size, maxSize), MeasureSpec.AT_MOST); - } - } - } - - @Override - public boolean onLayoutChild( - @NonNull CoordinatorLayout parent, @NonNull final V child, int layoutDirection) { - if (ViewCompat.getFitsSystemWindows(parent) && !ViewCompat.getFitsSystemWindows(child)) { - child.setFitsSystemWindows(true); - } - - if (viewRef == null) { - // First layout with this behavior. - peekHeightMin = - parent.getResources().getDimensionPixelSize(R.dimen.design_bottom_sheet_peek_height_min); - setWindowInsetsListener(child); - viewRef = new WeakReference<>(child); - // Only set MaterialShapeDrawable as background if shapeTheming is enabled, otherwise will - // default to android:background declared in styles or layout. - if (materialShapeDrawable != null) { - ViewCompat.setBackground(child, materialShapeDrawable); - // Use elevation attr if set on bottomsheet; otherwise, use elevation of child view. - materialShapeDrawable.setElevation( - elevation == -1 ? ViewCompat.getElevation(child) : elevation); - // Update the material shape based on initial state. - isShapeExpanded = state == STATE_EXPANDED; - materialShapeDrawable.setInterpolation(isShapeExpanded ? 0f : 1f); - } else if (backgroundTint != null) { - ViewCompat.setBackgroundTintList(child, backgroundTint); - } - updateAccessibilityActions(); - if (ViewCompat.getImportantForAccessibility(child) - == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO) { - ViewCompat.setImportantForAccessibility(child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES); - } - } - if (viewDragHelper == null) { - viewDragHelper = ViewDragHelper.create(parent, dragCallback); - } - - int savedTop = child.getTop(); - // First let the parent lay it out - parent.onLayoutChild(child, layoutDirection); - // Offset the bottom sheet - parentWidth = parent.getWidth(); - parentHeight = parent.getHeight(); - childHeight = child.getHeight(); - if (parentHeight - childHeight < insetTop) { - if (paddingTopSystemWindowInsets) { - // If the bottomsheet would land in the middle of the status bar when fully expanded add - // extra space to make sure it goes all the way. - childHeight = parentHeight; - } else { - // If we don't want the bottomsheet to go under the status bar we cap its height - childHeight = parentHeight - insetTop; - } - } - fitToContentsOffset = max(0, parentHeight - childHeight); - calculateHalfExpandedOffset(); - calculateCollapsedOffset(); - - if (state == STATE_EXPANDED) { - ViewCompat.offsetTopAndBottom(child, getExpandedOffset()); - } else if (state == STATE_HALF_EXPANDED) { - ViewCompat.offsetTopAndBottom(child, halfExpandedOffset); - } else if (hideable && state == STATE_HIDDEN) { - ViewCompat.offsetTopAndBottom(child, parentHeight); - } else if (state == STATE_COLLAPSED) { - ViewCompat.offsetTopAndBottom(child, collapsedOffset); - } else if (state == STATE_DRAGGING || state == STATE_SETTLING) { - ViewCompat.offsetTopAndBottom(child, savedTop - child.getTop()); - } - - nestedScrollingChildRef = new WeakReference<>(findScrollingChild(child)); - - for (int i = 0; i < callbacks.size(); i++) { - callbacks.get(i).onLayout(child); - } - return true; - } - - @Override - public boolean onInterceptTouchEvent( - @NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent event) { - if (!child.isShown() || !draggable) { - ignoreEvents = true; - return false; - } - int action = event.getActionMasked(); - // Record the velocity - if (action == MotionEvent.ACTION_DOWN) { - reset(); - } - if (velocityTracker == null) { - velocityTracker = VelocityTracker.obtain(); - } - velocityTracker.addMovement(event); - switch (action) { - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - touchingScrollingChild = false; - activePointerId = MotionEvent.INVALID_POINTER_ID; - // Reset the ignore flag - if (ignoreEvents) { - ignoreEvents = false; - return false; - } - break; - case MotionEvent.ACTION_DOWN: - int initialX = (int) event.getX(); - initialY = (int) event.getY(); - // Only intercept nested scrolling events here if the view not being moved by the - // ViewDragHelper. - if (state != STATE_SETTLING) { - View scroll = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null; - if (scroll != null && parent.isPointInChildBounds(scroll, initialX, initialY)) { - activePointerId = event.getPointerId(event.getActionIndex()); - touchingScrollingChild = true; - } - } - ignoreEvents = - activePointerId == MotionEvent.INVALID_POINTER_ID - && !parent.isPointInChildBounds(child, initialX, initialY); - break; - default: // fall out - } - if (!ignoreEvents - && viewDragHelper != null - && viewDragHelper.shouldInterceptTouchEvent(event)) { - return true; - } - // We have to handle cases that the ViewDragHelper does not capture the bottom sheet because - // it is not the top most view of its parent. This is not necessary when the touch event is - // happening over the scrolling content as nested scrolling logic handles that case. - View scroll = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null; - return action == MotionEvent.ACTION_MOVE - && scroll != null - && !ignoreEvents - && state != STATE_DRAGGING - && !parent.isPointInChildBounds(scroll, (int) event.getX(), (int) event.getY()) - && viewDragHelper != null - && Math.abs(initialY - event.getY()) > viewDragHelper.getTouchSlop(); - } - - @Override - public boolean onTouchEvent( - @NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent event) { - if (!child.isShown()) { - return false; - } - int action = event.getActionMasked(); - if (state == STATE_DRAGGING && action == MotionEvent.ACTION_DOWN) { - return true; - } - if (shouldHandleDraggingWithHelper()) { - viewDragHelper.processTouchEvent(event); - } - // Record the velocity - if (action == MotionEvent.ACTION_DOWN) { - reset(); - } - if (velocityTracker == null) { - velocityTracker = VelocityTracker.obtain(); - } - velocityTracker.addMovement(event); - // The ViewDragHelper tries to capture only the top-most View. We have to explicitly tell it - // to capture the bottom sheet in case it is not captured and the touch slop is passed. - if (shouldHandleDraggingWithHelper() && action == MotionEvent.ACTION_MOVE && !ignoreEvents) { - if (Math.abs(initialY - event.getY()) > viewDragHelper.getTouchSlop()) { - viewDragHelper.captureChildView(child, event.getPointerId(event.getActionIndex())); - } - } - return !ignoreEvents; - } - - @Override - public boolean onStartNestedScroll( - @NonNull CoordinatorLayout coordinatorLayout, - @NonNull V child, - @NonNull View directTargetChild, - @NonNull View target, - int axes, - int type) { - lastNestedScrollDy = 0; - nestedScrolled = false; - return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; - } - - @Override - public void onNestedPreScroll( - @NonNull CoordinatorLayout coordinatorLayout, - @NonNull V child, - @NonNull View target, - int dx, - int dy, - @NonNull int[] consumed, - int type) { - if (type == ViewCompat.TYPE_NON_TOUCH) { - // Ignore fling here. The ViewDragHelper handles it. - return; - } - View scrollingChild = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null; - if (isNestedScrollingCheckEnabled() && target != scrollingChild) { - return; - } - int currentTop = child.getTop(); - int newTop = currentTop - dy; - if (dy > 0) { // Upward - if (newTop < getExpandedOffset()) { - consumed[1] = currentTop - getExpandedOffset(); - ViewCompat.offsetTopAndBottom(child, -consumed[1]); - setStateInternal(STATE_EXPANDED); - } else { - if (!draggable) { - // Prevent dragging - return; - } - - consumed[1] = dy; - ViewCompat.offsetTopAndBottom(child, -dy); - setStateInternal(STATE_DRAGGING); - } - } else if (dy < 0) { // Downward - if (!target.canScrollVertically(-1)) { - if (newTop <= collapsedOffset || hideable) { - if (!draggable) { - // Prevent dragging - return; - } - - consumed[1] = dy; - ViewCompat.offsetTopAndBottom(child, -dy); - setStateInternal(STATE_DRAGGING); - } else { - consumed[1] = currentTop - collapsedOffset; - ViewCompat.offsetTopAndBottom(child, -consumed[1]); - setStateInternal(STATE_COLLAPSED); - } - } - } - dispatchOnSlide(child.getTop()); - lastNestedScrollDy = dy; - nestedScrolled = true; - } - - @Override - public void onStopNestedScroll( - @NonNull CoordinatorLayout coordinatorLayout, - @NonNull V child, - @NonNull View target, - int type) { - if (child.getTop() == getExpandedOffset()) { - setStateInternal(STATE_EXPANDED); - return; - } - if (isNestedScrollingCheckEnabled() - && (nestedScrollingChildRef == null - || target != nestedScrollingChildRef.get() - || !nestedScrolled)) { - return; - } - @StableState int targetState; - if (lastNestedScrollDy > 0) { - if (fitToContents) { - targetState = STATE_EXPANDED; - } else { - int currentTop = child.getTop(); - if (currentTop > halfExpandedOffset) { - targetState = STATE_HALF_EXPANDED; - } else { - targetState = STATE_EXPANDED; - } - } - } else if (hideable && shouldHide(child, getYVelocity())) { - targetState = STATE_HIDDEN; - } else if (lastNestedScrollDy == 0) { - int currentTop = child.getTop(); - if (fitToContents) { - if (Math.abs(currentTop - fitToContentsOffset) < Math.abs(currentTop - collapsedOffset)) { - targetState = STATE_EXPANDED; - } else { - targetState = STATE_COLLAPSED; - } - } else { - if (currentTop < halfExpandedOffset) { - if (currentTop < Math.abs(currentTop - collapsedOffset)) { - targetState = STATE_EXPANDED; - } else { - if (shouldSkipHalfExpandedStateWhenDragging()) { - targetState = STATE_COLLAPSED; - } else { - targetState = STATE_HALF_EXPANDED; - } - } - } else { - if (Math.abs(currentTop - halfExpandedOffset) < Math.abs(currentTop - collapsedOffset)) { - targetState = STATE_HALF_EXPANDED; - } else { - targetState = STATE_COLLAPSED; - } - } - } - } else { - if (fitToContents) { - targetState = STATE_COLLAPSED; - } else { - // Settle to nearest height. - int currentTop = child.getTop(); - if (Math.abs(currentTop - halfExpandedOffset) < Math.abs(currentTop - collapsedOffset)) { - targetState = STATE_HALF_EXPANDED; - } else { - targetState = STATE_COLLAPSED; - } - } - } - startSettling(child, targetState, false); - nestedScrolled = false; - } - - @Override - public void onNestedScroll( - @NonNull CoordinatorLayout coordinatorLayout, - @NonNull V child, - @NonNull View target, - int dxConsumed, - int dyConsumed, - int dxUnconsumed, - int dyUnconsumed, - int type, - @NonNull int[] consumed) { - // Overridden to prevent the default consumption of the entire scroll distance. - } - - @Override - public boolean onNestedPreFling( - @NonNull CoordinatorLayout coordinatorLayout, - @NonNull V child, - @NonNull View target, - float velocityX, - float velocityY) { - - if (isNestedScrollingCheckEnabled() && nestedScrollingChildRef != null) { - return target == nestedScrollingChildRef.get() - && (state != STATE_EXPANDED - || super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY)); - } else { - return false; - } - } - - /** - * @return whether the height of the expanded sheet is determined by the height of its contents, - * or if it is expanded in two stages (half the height of the parent container, full height of - * parent container). - */ - public boolean isFitToContents() { - return fitToContents; - } - - /** - * Sets whether the height of the expanded sheet is determined by the height of its contents, or - * if it is expanded in two stages (half the height of the parent container, full height of parent - * container). Default value is true. - * - * @param fitToContents whether or not to fit the expanded sheet to its contents. - */ - public void setFitToContents(boolean fitToContents) { - if (this.fitToContents == fitToContents) { - return; - } - this.fitToContents = fitToContents; - - // If sheet is already laid out, recalculate the collapsed offset based on new setting. - // Otherwise, let onLayoutChild handle this later. - if (viewRef != null) { - calculateCollapsedOffset(); - } - // Fix incorrect expanded settings depending on whether or not we are fitting sheet to contents. - setStateInternal((this.fitToContents && state == STATE_HALF_EXPANDED) ? STATE_EXPANDED : state); - - updateAccessibilityActions(); - } - - /** - * Sets the maximum width of the bottom sheet. The layout will be at most this dimension wide. - * This method should be called before {@link BottomSheetDialog#show()} in order for the width to - * be adjusted as expected. - * - * @param maxWidth The maximum width in pixels to be set - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxWidth - * @see #getMaxWidth() - */ - public void setMaxWidth(@Px int maxWidth) { - this.maxWidth = maxWidth; - } - - /** - * Returns the bottom sheet's maximum width, or -1 if no maximum width is set. - * - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxWidth - * @see #setMaxWidth(int) - */ - @Px - public int getMaxWidth() { - return maxWidth; - } - - /** - * Sets the maximum height of the bottom sheet. This method should be called before {@link - * BottomSheetDialog#show()} in order for the height to be adjusted as expected. - * - * @param maxHeight The maximum height in pixels to be set - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxHeight - * @see #getMaxHeight() - */ - public void setMaxHeight(@Px int maxHeight) { - this.maxHeight = maxHeight; - } - - /** - * Returns the bottom sheet's maximum height, or -1 if no maximum height is set. - * - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxHeight - * @see #setMaxHeight(int) - */ - @Px - public int getMaxHeight() { - return maxHeight; - } - - /** - * Sets the height of the bottom sheet when it is collapsed. - * - * @param peekHeight The height of the collapsed bottom sheet in pixels, or {@link - * #PEEK_HEIGHT_AUTO} to configure the sheet to peek automatically at 16:9 ratio keyline. - * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight - */ - public void setPeekHeight(int peekHeight) { - setPeekHeight(peekHeight, false); - } - - /** - * Sets the height of the bottom sheet when it is collapsed while optionally animating between the - * old height and the new height. - * - * @param peekHeight The height of the collapsed bottom sheet in pixels, or {@link - * #PEEK_HEIGHT_AUTO} to configure the sheet to peek automatically at 16:9 ratio keyline. - * @param animate Whether to animate between the old height and the new height. - * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight - */ - public final void setPeekHeight(int peekHeight, boolean animate) { - boolean layout = false; - if (peekHeight == PEEK_HEIGHT_AUTO) { - if (!peekHeightAuto) { - peekHeightAuto = true; - layout = true; - } - } else if (peekHeightAuto || this.peekHeight != peekHeight) { - peekHeightAuto = false; - this.peekHeight = max(0, peekHeight); - layout = true; - } - // If sheet is already laid out, recalculate the collapsed offset based on new setting. - // Otherwise, let onLayoutChild handle this later. - if (layout) { - updatePeekHeight(animate); - } - } - - private void updatePeekHeight(boolean animate) { - if (viewRef != null) { - calculateCollapsedOffset(); - if (state == STATE_COLLAPSED) { - V view = viewRef.get(); - if (view != null) { - if (animate) { - setState(STATE_COLLAPSED); - } else { - view.requestLayout(); - } - } - } - } - } - - /** - * Gets the height of the bottom sheet when it is collapsed. - * - * @return The height of the collapsed bottom sheet in pixels, or {@link #PEEK_HEIGHT_AUTO} if the - * sheet is configured to peek automatically at 16:9 ratio keyline - * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight - */ - public int getPeekHeight() { - return peekHeightAuto ? PEEK_HEIGHT_AUTO : peekHeight; - } - - /** - * Determines the height of the BottomSheet in the {@link #STATE_HALF_EXPANDED} state. The - * material guidelines recommended a value of 0.5, which results in the sheet filling half of the - * parent. The height of the BottomSheet will be smaller as this ratio is decreased and taller as - * it is increased. The default value is 0.5. - * - * @param ratio a float between 0 and 1, representing the {@link #STATE_HALF_EXPANDED} ratio. - * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_halfExpandedRatio - */ - public void setHalfExpandedRatio( - @FloatRange(from = 0.0f, to = 1.0f, fromInclusive = false, toInclusive = false) float ratio) { - - if ((ratio <= 0) || (ratio >= 1)) { - throw new IllegalArgumentException("ratio must be a float value between 0 and 1"); - } - this.halfExpandedRatio = ratio; - // If sheet is already laid out, recalculate the half expanded offset based on new setting. - // Otherwise, let onLayoutChild handle this later. - if (viewRef != null) { - calculateHalfExpandedOffset(); - } - } - - /** - * Gets the ratio for the height of the BottomSheet in the {@link #STATE_HALF_EXPANDED} state. - * - * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_halfExpandedRatio - */ - @FloatRange(from = 0.0f, to = 1.0f) - public float getHalfExpandedRatio() { - return halfExpandedRatio; - } - - /** - * Determines the top offset of the BottomSheet in the {@link #STATE_EXPANDED} state when - * fitsToContent is false. The default value is 0, which results in the sheet matching the - * parent's top. - * - * @param offset an integer value greater than equal to 0, representing the {@link - * #STATE_EXPANDED} offset. Value must not exceed the offset in the half expanded state. - * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_expandedOffset - */ - public void setExpandedOffset(int offset) { - if (offset < 0) { - throw new IllegalArgumentException("offset must be greater than or equal to 0"); - } - this.expandedOffset = offset; - } - - /** - * Returns the current expanded offset. If {@code fitToContents} is true, it will automatically - * pick the offset depending on the height of the content. - * - * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_expandedOffset - */ - public int getExpandedOffset() { - return fitToContents - ? fitToContentsOffset - : Math.max(expandedOffset, paddingTopSystemWindowInsets ? 0 : insetTop); - } - - /** - * Calculates the current offset of the bottom sheet. - * - * This method should be called when the child view is laid out. - * - * @return The offset of this bottom sheet within [-1,1] range. Offset increases - * as this bottom sheet is moving upward. From 0 to 1 the sheet is between collapsed and - * expanded states and from -1 to 0 it is between hidden and collapsed states. Returns - * -1 if the bottom sheet is not laid out (therefore it's hidden). - */ - public float calculateSlideOffset() { - if (viewRef == null || viewRef.get() == null) { - return -1; - } - - return calculateSlideOffsetWithTop(viewRef.get().getTop()); - } - - /** - * Sets whether this bottom sheet can hide. - * - * @param hideable {@code true} to make this bottom sheet hideable. - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_hideable - */ - public void setHideable(boolean hideable) { - if (this.hideable != hideable) { - this.hideable = hideable; - if (!hideable && state == STATE_HIDDEN) { - // Lift up to collapsed state - setState(STATE_COLLAPSED); - } - updateAccessibilityActions(); - } - } - - /** - * Gets whether this bottom sheet can hide when it is swiped down. - * - * @return {@code true} if this bottom sheet can hide. - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_hideable - */ - public boolean isHideable() { - return hideable; - } - - /** - * Sets whether this bottom sheet should skip the collapsed state when it is being hidden after it - * is expanded once. Setting this to true has no effect unless the sheet is hideable. - * - * @param skipCollapsed True if the bottom sheet should skip the collapsed state. - * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_skipCollapsed - */ - public void setSkipCollapsed(boolean skipCollapsed) { - this.skipCollapsed = skipCollapsed; - } - - /** - * Sets whether this bottom sheet should skip the collapsed state when it is being hidden after it - * is expanded once. - * - * @return Whether the bottom sheet should skip the collapsed state. - * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_skipCollapsed - */ - public boolean getSkipCollapsed() { - return skipCollapsed; - } - - /** - * Sets whether this bottom sheet is can be collapsed/expanded by dragging. Note: When disabling - * dragging, an app will require to implement a custom way to expand/collapse the bottom sheet - * - * @param draggable {@code false} to prevent dragging the sheet to collapse and expand - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_draggable - */ - public void setDraggable(boolean draggable) { - this.draggable = draggable; - } - - public boolean isDraggable() { - return draggable; - } - - /* - * Sets the velocity threshold considered significant enough to trigger a slide - * to the next stable state. - * - * @param significantVelocityThreshold The velocity threshold that warrants a vertical swipe. - * @see #getSignificantVelocityThreshold() - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_significantVelocityThreshold - */ - public void setSignificantVelocityThreshold(int significantVelocityThreshold) { - this.significantVelocityThreshold = significantVelocityThreshold; - } - - /* - * Returns the significant velocity threshold. - * - * @see #setSignificantVelocityThreshold(int) - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_significantVelocityThreshold - */ - public int getSignificantVelocityThreshold() { - return this.significantVelocityThreshold; - } - - /** - * Sets save flags to be preserved in bottomsheet on configuration change. - * - * @param flags bitwise int of {@link #SAVE_PEEK_HEIGHT}, {@link #SAVE_FIT_TO_CONTENTS}, {@link - * #SAVE_HIDEABLE}, {@link #SAVE_SKIP_COLLAPSED}, {@link #SAVE_ALL} and {@link #SAVE_NONE}. - * @see #getSaveFlags() - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_saveFlags - */ - public void setSaveFlags(@SaveFlags int flags) { - this.saveFlags = flags; - } - /** - * Returns the save flags. - * - * @see #setSaveFlags(int) - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_saveFlags - */ - @SaveFlags - public int getSaveFlags() { - return this.saveFlags; - } - - /** - * Sets the friction coefficient to hide the bottom sheet, or set it to the next closest - * expanded state. - * - * @param hideFriction The friction coefficient that determines the swipe velocity needed to - * hide or set the bottom sheet to the closest expanded state. - */ - public void setHideFriction(float hideFriction) { - this.hideFriction = hideFriction; - } - - /** - * Gets the friction coefficient to hide the bottom sheet, or set it to the next closest - * expanded state. - * - * @return The friction coefficient that determines the swipe velocity needed to hide or set the - * bottom sheet to the closest expanded state. - */ - public float getHideFriction() { - return this.hideFriction; - } - - /** - * Sets a callback to be notified of bottom sheet events. - * - * @param callback The callback to notify when bottom sheet events occur. - * @deprecated use {@link #addBottomSheetCallback(BottomSheetCallback)} and {@link - * #removeBottomSheetCallback(BottomSheetCallback)} instead - */ - @Deprecated - public void setBottomSheetCallback(BottomSheetCallback callback) { - Log.w( - TAG, - "BottomSheetBehavior now supports multiple callbacks. `setBottomSheetCallback()` removes" - + " all existing callbacks, including ones set internally by library authors, which" - + " may result in unintended behavior. This may change in the future. Please use" - + " `addBottomSheetCallback()` and `removeBottomSheetCallback()` instead to set your" - + " own callbacks."); - callbacks.clear(); - if (callback != null) { - callbacks.add(callback); - } - } - - /** - * Adds a callback to be notified of bottom sheet events. - * - * @param callback The callback to notify when bottom sheet events occur. - */ - public void addBottomSheetCallback(@NonNull BottomSheetCallback callback) { - if (!callbacks.contains(callback)) { - callbacks.add(callback); - } - } - - /** - * Removes a previously added callback. - * - * @param callback The callback to remove. - */ - public void removeBottomSheetCallback(@NonNull BottomSheetCallback callback) { - callbacks.remove(callback); - } - - /** - * Sets the state of the bottom sheet. The bottom sheet will transition to that state with - * animation. - * - * @param state One of {@link #STATE_COLLAPSED}, {@link #STATE_EXPANDED}, {@link #STATE_HIDDEN}, - * or {@link #STATE_HALF_EXPANDED}. - */ - public void setState(@StableState int state) { - if (state == STATE_DRAGGING || state == STATE_SETTLING) { - throw new IllegalArgumentException( - "STATE_" - + (state == STATE_DRAGGING ? "DRAGGING" : "SETTLING") - + " should not be set externally."); - } - if (!hideable && state == STATE_HIDDEN) { - Log.w(TAG, "Cannot set state: " + state); - return; - } - final int finalState; - if (state == STATE_HALF_EXPANDED - && fitToContents - && getTopOffsetForState(state) <= fitToContentsOffset) { - // Skip to the expanded state if we would scroll past the height of the contents. - finalState = STATE_EXPANDED; - } else { - finalState = state; - } - if (viewRef == null || viewRef.get() == null) { - // The view is not laid out yet; modify mState and let onLayoutChild handle it later - setStateInternal(state); - } else { - final V child = viewRef.get(); - runAfterLayout( - child, - new Runnable() { - @Override - public void run() { - startSettling(child, finalState, false); - } - }); - } - } - - private void runAfterLayout(V child, Runnable runnable) { - if (isLayouting(child)) { - child.post(runnable); - } else { - runnable.run(); - } - } - - private boolean isLayouting(V child) { - ViewParent parent = child.getParent(); - return parent != null && parent.isLayoutRequested() && ViewCompat.isAttachedToWindow(child); - } - - /** - * Sets whether this bottom sheet should adjust it's position based on the system gesture area on - * Android Q and above. - * - *

Note: the bottom sheet will only adjust it's position if it would be unable to be scrolled - * upwards because the peekHeight is less than the gesture inset margins,(because that would cause - * a gesture conflict), gesture navigation is enabled, and this {@code ignoreGestureInsetBottom} - * flag is false. - */ - public void setGestureInsetBottomIgnored(boolean gestureInsetBottomIgnored) { - this.gestureInsetBottomIgnored = gestureInsetBottomIgnored; - } - - /** - * Returns whether this bottom sheet should adjust it's position based on the system gesture area. - */ - public boolean isGestureInsetBottomIgnored() { - return gestureInsetBottomIgnored; - } - - /** - * Gets the current state of the bottom sheet. - * - * @return One of {@link #STATE_EXPANDED}, {@link #STATE_HALF_EXPANDED}, {@link #STATE_COLLAPSED}, - * {@link #STATE_DRAGGING}, or {@link #STATE_SETTLING}. - */ - @State - public int getState() { - return state; - } - - void setStateInternal(@State int state) { - if (this.state == state) { - return; - } - this.state = state; - if (state == STATE_COLLAPSED - || state == STATE_EXPANDED - || state == STATE_HALF_EXPANDED - || (hideable && state == STATE_HIDDEN)) { - this.lastStableState = state; - } - - if (viewRef == null) { - return; - } - - View bottomSheet = viewRef.get(); - if (bottomSheet == null) { - return; - } - - if (state == STATE_EXPANDED) { - updateImportantForAccessibility(true); - } else if (state == STATE_HALF_EXPANDED || state == STATE_HIDDEN || state == STATE_COLLAPSED) { - updateImportantForAccessibility(false); - } - - updateDrawableForTargetState(state); - for (int i = 0; i < callbacks.size(); i++) { - callbacks.get(i).onStateChanged(bottomSheet, state); - } - updateAccessibilityActions(); - } - - private void updateDrawableForTargetState(@State int state) { - if (state == STATE_SETTLING) { - // Special case: we want to know which state we're settling to, so wait for another call. - return; - } - - boolean expand = state == STATE_EXPANDED; - if (isShapeExpanded != expand) { - isShapeExpanded = expand; - if (materialShapeDrawable != null && interpolatorAnimator != null) { - if (interpolatorAnimator.isRunning()) { - interpolatorAnimator.reverse(); - } else { - float to = expand ? 0f : 1f; - float from = 1f - to; - interpolatorAnimator.setFloatValues(from, to); - interpolatorAnimator.start(); - } - } - } - } - - private int calculatePeekHeight() { - if (peekHeightAuto) { - int desiredHeight = max(peekHeightMin, parentHeight - parentWidth * 9 / 16); - return min(desiredHeight, childHeight) + insetBottom; - } - // Only make sure the peek height is above the gesture insets if we're not applying system - // insets. - - // MODIFICATION: always add insetBottom for peekHeight -// if (!gestureInsetBottomIgnored && !paddingBottomSystemWindowInsets && gestureInsetBottom > 0) { -// return max(peekHeight, gestureInsetBottom + peekHeightGestureInsetBuffer); -// } - return peekHeight + insetBottom; - } - - private void calculateCollapsedOffset() { - int peek = calculatePeekHeight(); - - if (fitToContents) { - collapsedOffset = max(parentHeight - peek, fitToContentsOffset); - } else { - collapsedOffset = parentHeight - peek; - } - } - - private void calculateHalfExpandedOffset() { - this.halfExpandedOffset = (int) (parentHeight * (1 - halfExpandedRatio)); - } - - private float calculateSlideOffsetWithTop(int top) { - return - (top > collapsedOffset || collapsedOffset == getExpandedOffset()) - ? (float) (collapsedOffset - top) / (parentHeight - collapsedOffset) - : (float) (collapsedOffset - top) / (collapsedOffset - getExpandedOffset()); - } - - private void reset() { - activePointerId = ViewDragHelper.INVALID_POINTER; - if (velocityTracker != null) { - velocityTracker.recycle(); - velocityTracker = null; - } - } - - private void restoreOptionalState(@NonNull SavedState ss) { - if (this.saveFlags == SAVE_NONE) { - return; - } - if (this.saveFlags == SAVE_ALL || (this.saveFlags & SAVE_PEEK_HEIGHT) == SAVE_PEEK_HEIGHT) { - this.peekHeight = ss.peekHeight; - } - if (this.saveFlags == SAVE_ALL - || (this.saveFlags & SAVE_FIT_TO_CONTENTS) == SAVE_FIT_TO_CONTENTS) { - this.fitToContents = ss.fitToContents; - } - if (this.saveFlags == SAVE_ALL || (this.saveFlags & SAVE_HIDEABLE) == SAVE_HIDEABLE) { - this.hideable = ss.hideable; - } - if (this.saveFlags == SAVE_ALL - || (this.saveFlags & SAVE_SKIP_COLLAPSED) == SAVE_SKIP_COLLAPSED) { - this.skipCollapsed = ss.skipCollapsed; - } - } - - boolean shouldHide(@NonNull View child, float yvel) { - if (skipCollapsed) { - return true; - } - if (child.getTop() < collapsedOffset) { - // It should not hide, but collapse. - return false; - } - int peek = calculatePeekHeight(); - final float newTop = child.getTop() + yvel * hideFriction; - return Math.abs(newTop - collapsedOffset) / (float) peek > HIDE_THRESHOLD; - } - - @Nullable - @VisibleForTesting - View findScrollingChild(View view) { - if (view.getVisibility() != View.VISIBLE) { - return null; - } - if (ViewCompat.isNestedScrollingEnabled(view)) { - return view; - } - if (view instanceof ViewGroup) { - ViewGroup group = (ViewGroup) view; - for (int i = 0, count = group.getChildCount(); i < count; i++) { - View scrollingChild = findScrollingChild(group.getChildAt(i)); - if (scrollingChild != null) { - return scrollingChild; - } - } - } - return null; - } - - private boolean shouldHandleDraggingWithHelper() { - // If it's not draggable, do not forward events to viewDragHelper; however, if it's already - // dragging, let it finish. - return viewDragHelper != null && (draggable || state == STATE_DRAGGING); - } - - private void createMaterialShapeDrawableIfNeeded(@NonNull Context context) { - if (shapeAppearanceModelDefault == null) { - return; - } - - this.materialShapeDrawable = new MaterialShapeDrawable(shapeAppearanceModelDefault); - this.materialShapeDrawable.initializeElevationOverlay(context); - - if (backgroundTint != null) { - materialShapeDrawable.setFillColor(backgroundTint); - } else { - // If the tint isn't set, use the theme default background color. - TypedValue defaultColor = new TypedValue(); - context.getTheme().resolveAttribute(android.R.attr.colorBackground, defaultColor, true); - materialShapeDrawable.setTint(defaultColor.data); - } - } - - MaterialShapeDrawable getMaterialShapeDrawable() { - return materialShapeDrawable; - } - - private void createShapeValueAnimator() { - interpolatorAnimator = ValueAnimator.ofFloat(0f, 1f); - interpolatorAnimator.setDuration(CORNER_ANIMATION_DURATION); - interpolatorAnimator.addUpdateListener( - new AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(@NonNull ValueAnimator animation) { - float value = (float) animation.getAnimatedValue(); - if (materialShapeDrawable != null) { - materialShapeDrawable.setInterpolation(value); - } - } - }); - } - - @SuppressLint("RestrictedApi") - private void setWindowInsetsListener(@NonNull View child) { - // Ensure the peek height is at least as large as the bottom gesture inset size so that - // the sheet can always be dragged, but only when the inset is required by the system. - final boolean shouldHandleGestureInsets = - VERSION.SDK_INT >= VERSION_CODES.Q && !isGestureInsetBottomIgnored() && !peekHeightAuto; - - // If were not handling insets at all, don't apply the listener. - if (!paddingBottomSystemWindowInsets - && !paddingLeftSystemWindowInsets - && !paddingRightSystemWindowInsets - && !marginLeftSystemWindowInsets - && !marginRightSystemWindowInsets - && !marginTopSystemWindowInsets - && !shouldHandleGestureInsets) { - return; - } - ViewUtils.doOnApplyWindowInsets( - child, - new ViewUtils.OnApplyWindowInsetsListener() { - @Override - @SuppressWarnings("deprecation") // getSystemWindowInsetBottom is used for adjustResize. - public WindowInsetsCompat onApplyWindowInsets( - View view, WindowInsetsCompat insets, RelativePadding initialPadding) { - Insets systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()); - Insets mandatoryGestureInsets = - insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()); - - insetTop = systemBarInsets.top; - - boolean isRtl = ViewUtils.isLayoutRtl(view); - - int bottomPadding = view.getPaddingBottom(); - int leftPadding = view.getPaddingLeft(); - int rightPadding = view.getPaddingRight(); - - // MODIFICATION: ignore paddingBottomSystemWindowInsets -// if (paddingBottomSystemWindowInsets) { - // Intentionally uses getSystemWindowInsetBottom to apply padding properly when - // adjustResize is used as the windowSoftInputMode. - insetBottom = insets.getSystemWindowInsetBottom(); -// bottomPadding = initialPadding.bottom + insetBottom; -// } - - if (paddingLeftSystemWindowInsets) { - leftPadding = isRtl ? initialPadding.end : initialPadding.start; - leftPadding += systemBarInsets.left; - } - - if (paddingRightSystemWindowInsets) { - rightPadding = isRtl ? initialPadding.start : initialPadding.end; - rightPadding += systemBarInsets.right; - } - - MarginLayoutParams mlp = (MarginLayoutParams) view.getLayoutParams(); - boolean marginUpdated = false; - - // MODIFIED: Don't change left and right margin to let landscape mode look normal - if (marginLeftSystemWindowInsets && mlp.leftMargin != systemBarInsets.left) { -// mlp.leftMargin = systemBarInsets.left; - marginUpdated = true; - } - - if (marginRightSystemWindowInsets && mlp.rightMargin != systemBarInsets.right) { -// mlp.rightMargin = systemBarInsets.right; - marginUpdated = true; - } - - if (marginTopSystemWindowInsets && mlp.topMargin != systemBarInsets.top) { - mlp.topMargin = systemBarInsets.top; - marginUpdated = true; - } - - if (marginUpdated) { - view.setLayoutParams(mlp); - } - view.setPadding(leftPadding, view.getPaddingTop(), rightPadding, bottomPadding); - - if (shouldHandleGestureInsets) { - gestureInsetBottom = mandatoryGestureInsets.bottom; - } - - // Don't update the peek height to be above the navigation bar or gestures if these - // flags are off. It means the client is already handling it. - - // MODIFICATION: always update peek height -// if (paddingBottomSystemWindowInsets || shouldHandleGestureInsets) { - updatePeekHeight(/* animate= */ false); -// } - return insets; - } - }); - } - - private float getYVelocity() { - if (velocityTracker == null) { - return 0; - } - velocityTracker.computeCurrentVelocity(1000, maximumVelocity); - return velocityTracker.getYVelocity(activePointerId); - } - - private void startSettling(View child, @StableState int state, boolean isReleasingView) { - int top = getTopOffsetForState(state); - boolean settling = - viewDragHelper != null - && (isReleasingView - ? viewDragHelper.settleCapturedViewAt(child.getLeft(), top) - : viewDragHelper.smoothSlideViewTo(child, child.getLeft(), top)); - if (settling) { - setStateInternal(STATE_SETTLING); - // STATE_SETTLING won't animate the material shape, so do that here with the target state. - updateDrawableForTargetState(state); - stateSettlingTracker.continueSettlingToState(state); - } else { - setStateInternal(state); - } - } - - private int getTopOffsetForState(@StableState int state) { - switch (state) { - case STATE_COLLAPSED: - return collapsedOffset; - case STATE_EXPANDED: - return getExpandedOffset(); - case STATE_HALF_EXPANDED: - return halfExpandedOffset; - case STATE_HIDDEN: - return parentHeight; - default: - // Fall through - } - throw new IllegalArgumentException("Invalid state to get top offset: " + state); - } - - private final ViewDragHelper.Callback dragCallback = - new ViewDragHelper.Callback() { - - private long viewCapturedMillis; - - @Override - public boolean tryCaptureView(@NonNull View child, int pointerId) { - if (state == STATE_DRAGGING) { - return false; - } - if (touchingScrollingChild) { - return false; - } - if (state == STATE_EXPANDED && activePointerId == pointerId) { - View scroll = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null; - if (scroll != null && scroll.canScrollVertically(-1)) { - // Let the content scroll up - return false; - } - } - viewCapturedMillis = System.currentTimeMillis(); - return viewRef != null && viewRef.get() == child; - } - - @Override - public void onViewPositionChanged( - @NonNull View changedView, int left, int top, int dx, int dy) { - dispatchOnSlide(top); - } - - @Override - public void onViewDragStateChanged(@State int state) { - if (state == ViewDragHelper.STATE_DRAGGING && draggable) { - setStateInternal(STATE_DRAGGING); - } - } - - private boolean releasedLow(@NonNull View child) { - // Needs to be at least half way to the bottom. - return child.getTop() > (parentHeight + getExpandedOffset()) / 2; - } - - @Override - public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) { - @State int targetState; - if (yvel < 0) { // Moving up - if (fitToContents) { - targetState = STATE_EXPANDED; - } else { - int currentTop = releasedChild.getTop(); - long dragDurationMillis = System.currentTimeMillis() - viewCapturedMillis; - - if (shouldSkipHalfExpandedStateWhenDragging()) { - float yPositionPercentage = currentTop * 100f / parentHeight; - - if (shouldExpandOnUpwardDrag(dragDurationMillis, yPositionPercentage)) { - targetState = STATE_EXPANDED; - } else { - targetState = STATE_COLLAPSED; - } - } else { - if (currentTop > halfExpandedOffset) { - targetState = STATE_HALF_EXPANDED; - } else { - targetState = STATE_EXPANDED; - } - } - } - } else if (hideable && shouldHide(releasedChild, yvel)) { - // Hide if the view was either released low or it was a significant vertical swipe - // otherwise settle to closest expanded state. - if ((Math.abs(xvel) < Math.abs(yvel) && yvel > significantVelocityThreshold) - || releasedLow(releasedChild)) { - targetState = STATE_HIDDEN; - } else if (fitToContents) { - targetState = STATE_EXPANDED; - } else if (Math.abs(releasedChild.getTop() - getExpandedOffset()) - < Math.abs(releasedChild.getTop() - halfExpandedOffset)) { - targetState = STATE_EXPANDED; - } else { - targetState = STATE_HALF_EXPANDED; - } - } else if (yvel == 0.f || Math.abs(xvel) > Math.abs(yvel)) { - // If the Y velocity is 0 or the swipe was mostly horizontal indicated by the X velocity - // being greater than the Y velocity, settle to the nearest correct height. - int currentTop = releasedChild.getTop(); - if (fitToContents) { - if (Math.abs(currentTop - fitToContentsOffset) - < Math.abs(currentTop - collapsedOffset)) { - targetState = STATE_EXPANDED; - } else { - targetState = STATE_COLLAPSED; - } - } else { - if (currentTop < halfExpandedOffset) { - if (currentTop < Math.abs(currentTop - collapsedOffset)) { - targetState = STATE_EXPANDED; - } else { - if (shouldSkipHalfExpandedStateWhenDragging()) { - targetState = STATE_COLLAPSED; - } else { - targetState = STATE_HALF_EXPANDED; - } - } - } else { - if (Math.abs(currentTop - halfExpandedOffset) - < Math.abs(currentTop - collapsedOffset)) { - if (shouldSkipHalfExpandedStateWhenDragging()) { - targetState = STATE_COLLAPSED; - } else { - targetState = STATE_HALF_EXPANDED; - } - } else { - targetState = STATE_COLLAPSED; - } - } - } - } else { // Moving Down - if (fitToContents) { - targetState = STATE_COLLAPSED; - } else { - // Settle to the nearest correct height. - int currentTop = releasedChild.getTop(); - if (Math.abs(currentTop - halfExpandedOffset) - < Math.abs(currentTop - collapsedOffset)) { - if (shouldSkipHalfExpandedStateWhenDragging()) { - targetState = STATE_COLLAPSED; - } else { - targetState = STATE_HALF_EXPANDED; - } - } else { - targetState = STATE_COLLAPSED; - } - } - } - startSettling(releasedChild, targetState, shouldSkipSmoothAnimation()); - } - - @Override - public int clampViewPositionVertical(@NonNull View child, int top, int dy) { - return MathUtils.clamp( - top, getExpandedOffset(), hideable ? parentHeight : collapsedOffset); - } - - @Override - public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) { - return child.getLeft(); - } - - @Override - public int getViewVerticalDragRange(@NonNull View child) { - if (hideable) { - return parentHeight; - } else { - return collapsedOffset; - } - } - }; - - void dispatchOnSlide(int top) { - View bottomSheet = viewRef.get(); - if (bottomSheet != null && !callbacks.isEmpty()) { - float slideOffset = calculateSlideOffsetWithTop(top); - for (int i = 0; i < callbacks.size(); i++) { - callbacks.get(i).onSlide(bottomSheet, slideOffset); - } - } - } - - @VisibleForTesting - int getPeekHeightMin() { - return peekHeightMin; - } - - /** - * Disables the shaped corner {@link ShapeAppearanceModel} interpolation transition animations. - * Will have no effect unless the sheet utilizes a {@link MaterialShapeDrawable} with set shape - * theming properties. Only For use in UI testing. - * - * @hide - */ - @RestrictTo(LIBRARY_GROUP) - @VisibleForTesting - public void disableShapeAnimations() { - // Sets the shape value animator to null, prevents animations from occurring during testing. - interpolatorAnimator = null; - } - - /** - * Checks weather a nested scroll should be enabled. If {@code false} all nested scrolls will be - * consumed by the bottomSheet. - * - * @hide - */ - @RestrictTo(LIBRARY_GROUP) - public boolean isNestedScrollingCheckEnabled() { - return true; - } - - /** - * Checks weather half expended state should be skipped when drag is ended. If {@code true}, the - * bottomSheet will go to the next closest state. - * - * @hide - */ - @RestrictTo(LIBRARY_GROUP) - public boolean shouldSkipHalfExpandedStateWhenDragging() { - return false; - } - - /** - * Checks whether an animation should be smooth after the bottomSheet is released after dragging. - * - * @hide - */ - @RestrictTo(LIBRARY_GROUP) - public boolean shouldSkipSmoothAnimation() { - return true; - } - - /** - * Checks whether the bottom sheet should be expanded after it has been released after dragging. - * - * @param dragDurationMillis how long the bottom sheet was dragged. - * @param yPositionPercentage position of the bottom sheet when released after dragging. Lower - * values mean that view was released closer to the top of the screen. - * @hide - */ - @RestrictTo(LIBRARY_GROUP) - public boolean shouldExpandOnUpwardDrag( - long dragDurationMillis, @FloatRange(from = 0.0f, to = 100.0f) float yPositionPercentage) { - return false; - } - - /** - * Sets whether this bottom sheet can hide when it is swiped down. - * - * @param hideable {@code true} to make this bottom sheet hideable. - * @hide - */ - @RestrictTo(LIBRARY_GROUP) - public void setHideableInternal(boolean hideable) { - this.hideable = hideable; - } - - /** - * Gets the last stable state of the bottom sheet. - * - * @return One of {@link #STATE_EXPANDED}, {@link #STATE_HALF_EXPANDED}, {@link #STATE_COLLAPSED}, - * {@link #STATE_HIDDEN}. - * @hide - */ - @State - @RestrictTo(LIBRARY_GROUP) - public int getLastStableState() { - return lastStableState; - } - - private class StateSettlingTracker { - @State private int targetState; - private boolean isContinueSettlingRunnablePosted; - - private final Runnable continueSettlingRunnable = - new Runnable() { - @Override - public void run() { - isContinueSettlingRunnablePosted = false; - if (viewDragHelper != null && viewDragHelper.continueSettling(true)) { - continueSettlingToState(targetState); - } else if (state == STATE_SETTLING) { - setStateInternal(targetState); - } - // In other cases, settling has been interrupted by certain UX interactions. Do nothing. - } - }; - - void continueSettlingToState(@State int targetState) { - if (viewRef == null || viewRef.get() == null) { - return; - } - this.targetState = targetState; - if (!isContinueSettlingRunnablePosted) { - ViewCompat.postOnAnimation(viewRef.get(), continueSettlingRunnable); - isContinueSettlingRunnablePosted = true; - } - } - } - - /** State persisted across instances */ - protected static class SavedState extends AbsSavedState { - @State final int state; - int peekHeight; - boolean fitToContents; - boolean hideable; - boolean skipCollapsed; - - public SavedState(@NonNull Parcel source) { - this(source, null); - } - - public SavedState(@NonNull Parcel source, ClassLoader loader) { - super(source, loader); - //noinspection ResourceType - state = source.readInt(); - peekHeight = source.readInt(); - fitToContents = source.readInt() == 1; - hideable = source.readInt() == 1; - skipCollapsed = source.readInt() == 1; - } - - public SavedState(Parcelable superState, @NonNull NeoBottomSheetBehavior behavior) { - super(superState); - this.state = behavior.state; - this.peekHeight = behavior.peekHeight; - this.fitToContents = behavior.fitToContents; - this.hideable = behavior.hideable; - this.skipCollapsed = behavior.skipCollapsed; - } - - /** - * This constructor does not respect flags: {@link NeoBottomSheetBehavior#SAVE_PEEK_HEIGHT}, {@link - * NeoBottomSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link NeoBottomSheetBehavior#SAVE_HIDEABLE}, {@link - * NeoBottomSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link NeoBottomSheetBehavior#SAVE_NONE} - * were set. - * - * @deprecated Use {@link #SavedState(Parcelable, NeoBottomSheetBehavior)} instead. - */ - @Deprecated - public SavedState(Parcelable superstate, @State int state) { - super(superstate); - this.state = state; - } - - @Override - public void writeToParcel(@NonNull Parcel out, int flags) { - super.writeToParcel(out, flags); - out.writeInt(state); - out.writeInt(peekHeight); - out.writeInt(fitToContents ? 1 : 0); - out.writeInt(hideable ? 1 : 0); - out.writeInt(skipCollapsed ? 1 : 0); - } - - public static final Creator CREATOR = - new ClassLoaderCreator() { - @NonNull - @Override - public SavedState createFromParcel(@NonNull Parcel in, ClassLoader loader) { - return new SavedState(in, loader); - } - - @Nullable - @Override - public SavedState createFromParcel(@NonNull Parcel in) { - return new SavedState(in, null); - } - - @NonNull - @Override - public SavedState[] newArray(int size) { - return new SavedState[size]; - } - }; - } - - /** - * A utility function to get the {@link NeoBottomSheetBehavior} associated with the {@code view}. - * - * @param view The {@link View} with {@link NeoBottomSheetBehavior}. - * @return The {@link NeoBottomSheetBehavior} associated with the {@code view}. - */ - @NonNull - @SuppressWarnings("unchecked") - public static NeoBottomSheetBehavior from(@NonNull V view) { - ViewGroup.LayoutParams params = view.getLayoutParams(); - if (!(params instanceof CoordinatorLayout.LayoutParams)) { - throw new IllegalArgumentException("The view is not a child of CoordinatorLayout"); - } - CoordinatorLayout.Behavior behavior = - ((CoordinatorLayout.LayoutParams) params).getBehavior(); - if (!(behavior instanceof NeoBottomSheetBehavior)) { - throw new IllegalArgumentException("The view is not associated with NeoBottomSheetBehavior"); - } - return (NeoBottomSheetBehavior) behavior; - } - - /** - * Sets whether the BottomSheet should update the accessibility status of its {@link - * CoordinatorLayout} siblings when expanded. - * - *

Set this to true if the expanded state of the sheet blocks access to siblings (e.g., when - * the sheet expands over the full screen). - */ - public void setUpdateImportantForAccessibilityOnSiblings( - boolean updateImportantForAccessibilityOnSiblings) { - this.updateImportantForAccessibilityOnSiblings = updateImportantForAccessibilityOnSiblings; - } - - private void updateImportantForAccessibility(boolean expanded) { - if (viewRef == null) { - return; - } - - ViewParent viewParent = viewRef.get().getParent(); - if (!(viewParent instanceof CoordinatorLayout)) { - return; - } - - CoordinatorLayout parent = (CoordinatorLayout) viewParent; - final int childCount = parent.getChildCount(); - if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) && expanded) { - if (importantForAccessibilityMap == null) { - importantForAccessibilityMap = new HashMap<>(childCount); - } else { - // The important for accessibility values of the child views have been saved already. - return; - } - } - - for (int i = 0; i < childCount; i++) { - final View child = parent.getChildAt(i); - if (child == viewRef.get()) { - continue; - } - - if (expanded) { - // Saves the important for accessibility value of the child view. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - importantForAccessibilityMap.put(child, child.getImportantForAccessibility()); - } - if (updateImportantForAccessibilityOnSiblings) { - ViewCompat.setImportantForAccessibility( - child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); - } - } else { - if (updateImportantForAccessibilityOnSiblings - && importantForAccessibilityMap != null - && importantForAccessibilityMap.containsKey(child)) { - // Restores the original important for accessibility value of the child view. - ViewCompat.setImportantForAccessibility(child, importantForAccessibilityMap.get(child)); - } - } - } - - if (!expanded) { - importantForAccessibilityMap = null; - } else if (updateImportantForAccessibilityOnSiblings) { - // If the siblings of the bottom sheet have been set to not important for a11y, move the focus - // to the bottom sheet when expanded. - viewRef.get().sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED); - } - } - - private void updateAccessibilityActions() { - if (viewRef == null) { - return; - } - V child = viewRef.get(); - if (child == null) { - return; - } - ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_COLLAPSE); - ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_EXPAND); - ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_DISMISS); - - if (expandHalfwayActionId != View.NO_ID) { - ViewCompat.removeAccessibilityAction(child, expandHalfwayActionId); - } - if (!fitToContents && state != STATE_HALF_EXPANDED) { - expandHalfwayActionId = - addAccessibilityActionForState( - child, R.string.bottomsheet_action_expand_halfway, STATE_HALF_EXPANDED); - } - - if (hideable && state != STATE_HIDDEN) { - replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_DISMISS, STATE_HIDDEN); - } - - switch (state) { - case STATE_EXPANDED: - { - int nextState = fitToContents ? STATE_COLLAPSED : STATE_HALF_EXPANDED; - replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_COLLAPSE, nextState); - break; - } - case STATE_HALF_EXPANDED: - { - replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_COLLAPSE, STATE_COLLAPSED); - replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_EXPAND, STATE_EXPANDED); - break; - } - case STATE_COLLAPSED: - { - int nextState = fitToContents ? STATE_EXPANDED : STATE_HALF_EXPANDED; - replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_EXPAND, nextState); - break; - } - default: // fall out - } - } - - private void replaceAccessibilityActionForState( - V child, AccessibilityActionCompat action, @State int state) { - ViewCompat.replaceAccessibilityAction( - child, action, null, createAccessibilityViewCommandForState(state)); - } - - private int addAccessibilityActionForState( - V child, @StringRes int stringResId, @State int state) { - return ViewCompat.addAccessibilityAction( - child, - child.getResources().getString(stringResId), - createAccessibilityViewCommandForState(state)); - } - - private AccessibilityViewCommand createAccessibilityViewCommandForState(@State final int state) { - return new AccessibilityViewCommand() { - @Override - public boolean perform(@NonNull View view, @Nullable CommandArguments arguments) { - setState(state); - return true; - } - }; - } -} diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt index dc9985c00..e8113327e 100644 --- a/app/src/main/java/com/zionhuang/music/App.kt +++ b/app/src/main/java/com/zionhuang/music/App.kt @@ -1,61 +1,57 @@ package com.zionhuang.music import android.app.Application -import android.content.SharedPreferences import android.os.Build -import android.util.Log import android.widget.Toast import android.widget.Toast.LENGTH_SHORT -import androidx.core.content.edit +import androidx.datastore.preferences.core.edit import coil.ImageLoader import coil.ImageLoaderFactory import coil.disk.DiskCache import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.YouTubeLocale import com.zionhuang.kugou.KuGou -import com.zionhuang.music.constants.Constants.INNERTUBE_COOKIE -import com.zionhuang.music.constants.Constants.VISITOR_DATA -import com.zionhuang.music.extensions.getEnum -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.extensions.toInetSocketAddress -import com.zionhuang.music.ui.fragments.settings.StorageSettingsFragment.Companion.VALUE_TO_MB +import com.zionhuang.music.constants.* +import com.zionhuang.music.extensions.* +import com.zionhuang.music.utils.dataStore +import com.zionhuang.music.utils.get +import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import timber.log.Timber import java.net.Proxy import java.util.* -class App : Application(), ImageLoaderFactory, SharedPreferences.OnSharedPreferenceChangeListener { +@HiltAndroidApp +class App : Application(), ImageLoaderFactory { @OptIn(DelicateCoroutinesApi::class) override fun onCreate() { super.onCreate() - INSTANCE = this + Timber.plant(Timber.DebugTree()) - val systemDefault = getString(R.string.default_localization_key) val locale = Locale.getDefault() val languageTag = locale.toLanguageTag().replace("-Hant", "") // replace zh-Hant-* to zh-* - val languageCodes = resources.getStringArray(R.array.language_codes) - val countryCodes = resources.getStringArray(R.array.country_codes) YouTube.locale = YouTubeLocale( - gl = sharedPreferences.getString(getString(R.string.pref_content_country), systemDefault).takeIf { it != systemDefault } - ?: locale.country.takeIf { it in countryCodes } + gl = dataStore[ContentCountryKey]?.takeIf { it != SYSTEM_DEFAULT } + ?: locale.country.takeIf { it in CountryCodeToName } ?: "US", - hl = sharedPreferences.getString(getString(R.string.pref_content_language), systemDefault).takeIf { it != systemDefault } - ?: locale.language.takeIf { it in languageCodes } - ?: languageTag.takeIf { it in languageCodes } + hl = dataStore[ContentLanguageKey]?.takeIf { it != SYSTEM_DEFAULT } + ?: locale.language.takeIf { it in LanguageCodeToName } + ?: languageTag.takeIf { it in LanguageCodeToName } ?: "en" ) if (languageTag == "zh-TW") { KuGou.useTraditionalChinese = true } - Log.d("App", "${YouTube.locale}") - if (sharedPreferences.getBoolean(getString(R.string.pref_proxy_enabled), false)) { + if (dataStore[ProxyEnabledKey] == true) { try { - val socketAddress = sharedPreferences.getString(getString(R.string.pref_proxy_url), "")!!.toInetSocketAddress() YouTube.proxy = Proxy( - sharedPreferences.getEnum(getString(R.string.pref_proxy_type), Proxy.Type.HTTP), - socketAddress + dataStore[ProxyTypeKey].toEnum(defaultValue = Proxy.Type.HTTP), + dataStore[ProxyUrlKey]!!.toInetSocketAddress() ) } catch (e: Exception) { Toast.makeText(this, "Failed to parse proxy url.", LENGTH_SHORT).show() @@ -64,20 +60,26 @@ class App : Application(), ImageLoaderFactory, SharedPreferences.OnSharedPrefere } GlobalScope.launch { - YouTube.visitorData = sharedPreferences.getString(VISITOR_DATA, null) ?: YouTube.generateVisitorData().getOrNull()?.also { - sharedPreferences.edit { - putString(VISITOR_DATA, it) + dataStore.data + .map { it[VisitorDataKey] } + .distinctUntilChanged() + .collect { visitorData -> + YouTube.visitorData = visitorData + ?.takeIf { it != "null" } // Previously visitorData was sometimes saved as "null" due to a bug + ?: YouTube.visitorData().getOrNull()?.also { newVisitorData -> + dataStore.edit { settings -> + settings[VisitorDataKey] = newVisitorData + } + } ?: YouTube.DEFAULT_VISITOR_DATA } - } ?: YouTube.DEFAULT_VISITOR_DATA } - YouTube.cookie = sharedPreferences.getString(INNERTUBE_COOKIE, null) - sharedPreferences.registerOnSharedPreferenceChangeListener(this) - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when (key) { - VISITOR_DATA -> YouTube.visitorData = sharedPreferences.getString(VISITOR_DATA, null) ?: YouTube.DEFAULT_VISITOR_DATA - INNERTUBE_COOKIE -> YouTube.cookie = sharedPreferences.getString(INNERTUBE_COOKIE, null) + GlobalScope.launch { + dataStore.data + .map { it[InnerTubeCookieKey] } + .distinctUntilChanged() + .collect { cookie -> + YouTube.cookie = cookie + } } } @@ -88,15 +90,8 @@ class App : Application(), ImageLoaderFactory, SharedPreferences.OnSharedPrefere .diskCache( DiskCache.Builder() .directory(cacheDir.resolve("coil")) - .maxSizeBytes( - size = (VALUE_TO_MB.getOrNull( - sharedPreferences.getInt(getString(R.string.pref_image_max_cache_size), 0) - ) ?: 1024) * 1024 * 1024L) + .maxSizeBytes((dataStore[MaxImageCacheSizeKey] ?: 512) * 1024 * 1024L) .build() ) .build() - - companion object { - lateinit var INSTANCE: App - } } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt new file mode 100644 index 000000000..06de681b4 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -0,0 +1,751 @@ +package com.zionhuang.music + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.* +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.net.toUri +import androidx.core.util.Consumer +import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import coil.imageLoader +import coil.request.ImageRequest +import com.google.common.util.concurrent.MoreExecutors +import com.valentinilk.shimmer.LocalShimmerTheme +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.music.constants.* +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID +import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID +import com.zionhuang.music.db.entities.SearchHistory +import com.zionhuang.music.extensions.* +import com.zionhuang.music.playback.DownloadUtil +import com.zionhuang.music.playback.MusicService +import com.zionhuang.music.playback.MusicService.MusicBinder +import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.ui.component.* +import com.zionhuang.music.ui.component.shimmer.ShimmerTheme +import com.zionhuang.music.ui.menu.YouTubeSongMenu +import com.zionhuang.music.ui.player.BottomSheetPlayer +import com.zionhuang.music.ui.screens.* +import com.zionhuang.music.ui.screens.artist.ArtistItemsScreen +import com.zionhuang.music.ui.screens.artist.ArtistScreen +import com.zionhuang.music.ui.screens.artist.ArtistSongsScreen +import com.zionhuang.music.ui.screens.library.LibraryAlbumsScreen +import com.zionhuang.music.ui.screens.library.LibraryArtistsScreen +import com.zionhuang.music.ui.screens.library.LibraryPlaylistsScreen +import com.zionhuang.music.ui.screens.library.LibrarySongsScreen +import com.zionhuang.music.ui.screens.playlist.BuiltInPlaylistScreen +import com.zionhuang.music.ui.screens.playlist.LocalPlaylistScreen +import com.zionhuang.music.ui.screens.playlist.OnlinePlaylistScreen +import com.zionhuang.music.ui.screens.search.LocalSearchScreen +import com.zionhuang.music.ui.screens.search.OnlineSearchResult +import com.zionhuang.music.ui.screens.search.OnlineSearchScreen +import com.zionhuang.music.ui.screens.settings.* +import com.zionhuang.music.ui.theme.* +import com.zionhuang.music.ui.utils.appBarScrollBehavior +import com.zionhuang.music.ui.utils.canNavigateUp +import com.zionhuang.music.ui.utils.resetHeightOffset +import com.zionhuang.music.utils.dataStore +import com.zionhuang.music.utils.get +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + @Inject + lateinit var database: MusicDatabase + + @Inject + lateinit var downloadUtil: DownloadUtil + + private var playerConnection by mutableStateOf(null) + private var mediaController: MediaController? = null + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + if (service is MusicBinder) { + playerConnection = PlayerConnection(service, database, lifecycleScope) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + playerConnection?.dispose() + playerConnection = null + } + } + + override fun onStart() { + super.onStart() + bindService(Intent(this, MusicService::class.java), serviceConnection, Context.BIND_AUTO_CREATE) + } + + override fun onStop() { + super.onStop() + unbindService(serviceConnection) + } + + override fun onDestroy() { + super.onDestroy() + mediaController?.release() + } + + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + + // Connect to service so that notification and background playing will work + val sessionToken = SessionToken(this, ComponentName(this, MusicService::class.java)) + val controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() + controllerFuture.addListener( + { mediaController = controllerFuture.get() }, + MoreExecutors.directExecutor() + ) + + + setContent { + val enableDynamicTheme by rememberPreference(DynamicThemeKey, defaultValue = true) + val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val pureBlack by rememberPreference(PureBlackKey, defaultValue = false) + val isSystemInDarkTheme = isSystemInDarkTheme() + val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { + if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON + } + LaunchedEffect(useDarkTheme) { + setSystemBarAppearance(useDarkTheme) + } + var themeColor by rememberSaveable(stateSaver = ColorSaver) { + mutableStateOf(DefaultThemeColor) + } + + LaunchedEffect(playerConnection, enableDynamicTheme, isSystemInDarkTheme) { + val playerConnection = playerConnection + if (!enableDynamicTheme || playerConnection == null) { + themeColor = DefaultThemeColor + return@LaunchedEffect + } + playerConnection.service.currentMediaMetadata.collectLatest { song -> + themeColor = if (song != null) { + withContext(Dispatchers.IO) { + val result = imageLoader.execute( + ImageRequest.Builder(this@MainActivity) + .data(song.thumbnailUrl) + .allowHardware(false) // pixel access is not supported on Config#HARDWARE bitmaps + .build() + ) + (result.drawable as? BitmapDrawable)?.bitmap?.extractThemeColor() ?: DefaultThemeColor + } + } else DefaultThemeColor + } + } + + InnerTuneTheme( + darkTheme = useDarkTheme, + pureBlack = pureBlack, + themeColor = themeColor + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + val focusManager = LocalFocusManager.current + val density = LocalDensity.current + val windowsInsets = WindowInsets.systemBars + val bottomInset = with(density) { windowsInsets.getBottom(density).toDp() } + + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + + val navigationItems = remember { + listOf(Screens.Home, Screens.Songs, Screens.Artists, Screens.Albums, Screens.Playlists) + } + val defaultOpenTab = remember { + dataStore[DefaultOpenTabKey].toEnum(defaultValue = NavigationTab.HOME) + } + + val (query, onQueryChange) = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + var active by rememberSaveable { + mutableStateOf(false) + } + val onActiveChange: (Boolean) -> Unit = { newActive -> + active = newActive + if (!newActive) { + focusManager.clearFocus() + if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) { + onQueryChange(TextFieldValue()) + } + } + } + var searchSource by rememberEnumPreference(SearchSourceKey, SearchSource.ONLINE) + + val onSearch: (String) -> Unit = { + if (it.isNotEmpty()) { + onActiveChange(false) + navController.navigate("search/$it") + if (dataStore[PauseSearchHistoryKey] != true) { + database.query { + insert(SearchHistory(query = it)) + } + } + } + } + + val shouldShowSearchBar = remember(active, navBackStackEntry) { + active || navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } || + navBackStackEntry?.destination?.route?.startsWith("search/") == true + } + val shouldShowNavigationBar = remember(navBackStackEntry, active) { + navBackStackEntry?.destination?.route == null || + navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } && !active + } + val navigationBarHeight by animateDpAsState( + targetValue = if (shouldShowNavigationBar) NavigationBarHeight else 0.dp, + animationSpec = NavigationBarAnimationSpec, + label = "" + ) + + val playerBottomSheetState = rememberBottomSheetState( + dismissedBound = 0.dp, + collapsedBound = bottomInset + (if (shouldShowNavigationBar) NavigationBarHeight else 0.dp) + MiniPlayerHeight, + expandedBound = maxHeight, + ) + + val playerAwareWindowInsets = remember(bottomInset, shouldShowNavigationBar, playerBottomSheetState.isDismissed) { + var bottom = bottomInset + if (shouldShowNavigationBar) bottom += NavigationBarHeight + if (!playerBottomSheetState.isDismissed) bottom += MiniPlayerHeight + windowsInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + .add(WindowInsets(top = AppBarHeight, bottom = bottom)) + } + + val scrollBehavior = appBarScrollBehavior( + canScroll = { + navBackStackEntry?.destination?.route?.startsWith("search/") == false && + (playerBottomSheetState.isCollapsed || playerBottomSheetState.isDismissed) + } + ) + + LaunchedEffect(navBackStackEntry) { + if (navBackStackEntry?.destination?.route?.startsWith("search/") == true) { + val searchQuery = navBackStackEntry?.arguments?.getString("query")!! + onQueryChange(TextFieldValue(searchQuery, TextRange(searchQuery.length))) + } else if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) { + onQueryChange(TextFieldValue()) + } + scrollBehavior.state.resetHeightOffset() + } + LaunchedEffect(active) { + if (active) { + scrollBehavior.state.resetHeightOffset() + } + } + + LaunchedEffect(playerConnection) { + val player = playerConnection?.player ?: return@LaunchedEffect + if (player.currentMediaItem == null) { + if (!playerBottomSheetState.isDismissed) { + playerBottomSheetState.dismiss() + } + } else { + if (playerBottomSheetState.isDismissed) { + playerBottomSheetState.collapseSoft() + } + } + } + + DisposableEffect(playerConnection, playerBottomSheetState) { + val player = playerConnection?.player ?: return@DisposableEffect onDispose { } + val listener = object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && mediaItem != null && playerBottomSheetState.isDismissed) { + playerBottomSheetState.collapseSoft() + } + } + } + player.addListener(listener) + onDispose { + player.removeListener(listener) + } + } + + val coroutineScope = rememberCoroutineScope() + var sharedSong: SongItem? by remember { + mutableStateOf(null) + } + DisposableEffect(Unit) { + val listener = Consumer { intent -> + val uri = intent.data ?: intent.extras?.getString(Intent.EXTRA_TEXT)?.toUri() ?: return@Consumer + when (val path = uri.pathSegments.firstOrNull()) { + "playlist" -> uri.getQueryParameter("list")?.let { playlistId -> + if (playlistId.startsWith("OLAK5uy_")) { + coroutineScope.launch { + YouTube.albumSongs(playlistId).onSuccess { songs -> + songs.firstOrNull()?.album?.id?.let { browseId -> + navController.navigate("album/$browseId") + } + } + } + } else { + navController.navigate("online_playlist/$playlistId") + } + } + + "channel", "c" -> uri.lastPathSegment?.let { artistId -> + navController.navigate("artist/$artistId") + } + + else -> when { + path == "watch" -> uri.getQueryParameter("v") + uri.host == "youtu.be" -> path + else -> null + }?.let { videoId -> + coroutineScope.launch { + withContext(Dispatchers.IO) { + YouTube.queue(listOf(videoId)) + }.onSuccess { + sharedSong = it.firstOrNull() + } + } + } + } + } + + addOnNewIntentListener(listener) + onDispose { removeOnNewIntentListener(listener) } + } + + CompositionLocalProvider( + LocalDatabase provides database, + LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.background), + LocalPlayerConnection provides playerConnection, + LocalPlayerAwareWindowInsets provides playerAwareWindowInsets, + LocalDownloadUtil provides downloadUtil, + LocalShimmerTheme provides ShimmerTheme + ) { + NavHost( + navController = navController, + startDestination = when (defaultOpenTab) { + NavigationTab.HOME -> Screens.Home + NavigationTab.SONG -> Screens.Songs + NavigationTab.ARTIST -> Screens.Artists + NavigationTab.ALBUM -> Screens.Albums + NavigationTab.PLAYLIST -> Screens.Playlists + }.route, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + composable(Screens.Home.route) { + HomeScreen(navController) + } + composable(Screens.Songs.route) { + LibrarySongsScreen(navController) + } + composable(Screens.Artists.route) { + LibraryArtistsScreen(navController) + } + composable(Screens.Albums.route) { + LibraryAlbumsScreen(navController) + } + composable(Screens.Playlists.route) { + LibraryPlaylistsScreen(navController) + } + composable("history") { + HistoryScreen(navController) + } + composable("stats") { + StatsScreen(navController) + } + composable("new_release") { + NewReleaseScreen(navController, scrollBehavior) + } + composable( + route = "search/{query}", + arguments = listOf( + navArgument("query") { + type = NavType.StringType + } + ) + ) { + OnlineSearchResult(navController) + } + composable( + route = "album/{albumId}", + arguments = listOf( + navArgument("albumId") { + type = NavType.StringType + }, + ) + ) { + AlbumScreen(navController, scrollBehavior) + } + composable( + route = "artist/{artistId}", + arguments = listOf( + navArgument("artistId") { + type = NavType.StringType + } + ) + ) { backStackEntry -> + val artistId = backStackEntry.arguments?.getString("artistId")!! + if (artistId.startsWith("LA")) { + ArtistSongsScreen(navController, scrollBehavior) + } else { + ArtistScreen(navController, scrollBehavior) + } + } + composable( + route = "artist/{artistId}/songs", + arguments = listOf( + navArgument("artistId") { + type = NavType.StringType + } + ) + ) { + ArtistSongsScreen(navController, scrollBehavior) + } + composable( + route = "artist/{artistId}/items?browseId={browseId}?params={params}", + arguments = listOf( + navArgument("artistId") { + type = NavType.StringType + }, + navArgument("browseId") { + type = NavType.StringType + nullable = true + }, + navArgument("params") { + type = NavType.StringType + nullable = true + } + ) + ) { + ArtistItemsScreen(navController, scrollBehavior) + } + composable( + route = "online_playlist/{playlistId}", + arguments = listOf( + navArgument("playlistId") { + type = NavType.StringType + } + ) + ) { + OnlinePlaylistScreen(navController, scrollBehavior) + } + composable( + route = "local_playlist/{playlistId}", + arguments = listOf( + navArgument("playlistId") { + type = NavType.StringType + } + ) + ) { backStackEntry -> + val playlistId = backStackEntry.arguments?.getString("playlistId")!! + if (playlistId == LIKED_PLAYLIST_ID || playlistId == DOWNLOADED_PLAYLIST_ID) { + BuiltInPlaylistScreen(navController, scrollBehavior) + } else { + LocalPlaylistScreen(navController, scrollBehavior) + } + } + composable("settings") { + SettingsScreen(navController, scrollBehavior) + } + composable("settings/appearance") { + AppearanceSettings(navController, scrollBehavior) + } + composable("settings/content") { + ContentSettings(navController, scrollBehavior) + } + composable("settings/player") { + PlayerSettings(navController, scrollBehavior) + } + composable("settings/storage") { + StorageSettings(navController, scrollBehavior) + } + composable("settings/privacy") { + PrivacySettings(navController, scrollBehavior) + } + composable("settings/backup_restore") { + BackupAndRestore(navController, scrollBehavior) + } + composable("settings/about") { + AboutScreen(navController, scrollBehavior) + } + } + + AnimatedVisibility( + visible = shouldShowSearchBar, + enter = fadeIn(), + exit = fadeOut() + ) { + SearchBar( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + active = active, + onActiveChange = onActiveChange, + scrollBehavior = scrollBehavior, + placeholder = { + Text( + text = stringResource( + if (!active) R.string.search + else when (searchSource) { + SearchSource.LOCAL -> R.string.search_library + SearchSource.ONLINE -> R.string.search_yt_music + } + ) + ) + }, + leadingIcon = { + IconButton(onClick = { + when { + active -> onActiveChange(false) + navController.canNavigateUp && !navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } -> { + navController.navigateUp() + } + + else -> onActiveChange(true) + } + }) { + Icon( + painterResource( + if (active || (navController.canNavigateUp && !navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route })) { + R.drawable.arrow_back + } else { + R.drawable.search + } + ), + contentDescription = null + ) + } + }, + trailingIcon = { + if (active) { + if (query.text.isNotEmpty()) { + IconButton( + onClick = { onQueryChange(TextFieldValue("")) } + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } + IconButton( + onClick = { + searchSource = if (searchSource == SearchSource.ONLINE) SearchSource.LOCAL else SearchSource.ONLINE + } + ) { + Icon( + painter = painterResource( + when (searchSource) { + SearchSource.LOCAL -> R.drawable.library_music + SearchSource.ONLINE -> R.drawable.language + } + ), + contentDescription = null + ) + } + } + }, + modifier = Modifier.align(Alignment.TopCenter) + ) { + Crossfade( + targetState = searchSource, + label = "", + modifier = Modifier + .fillMaxSize() + .padding(bottom = if (!playerBottomSheetState.isDismissed) MiniPlayerHeight else 0.dp) + .navigationBarsPadding() + ) { searchSource -> + when (searchSource) { + SearchSource.LOCAL -> LocalSearchScreen( + query = query.text, + navController = navController, + onDismiss = { onActiveChange(false) } + ) + + SearchSource.ONLINE -> OnlineSearchScreen( + query = query.text, + onQueryChange = onQueryChange, + navController = navController, + onSearch = { + navController.navigate("search/$it") + if (dataStore[PauseSearchHistoryKey] != true) { + database.query { + insert(SearchHistory(query = it)) + } + } + }, + onDismiss = { onActiveChange(false) } + ) + } + } + } + } + + BottomSheetPlayer( + state = playerBottomSheetState, + navController = navController + ) + + NavigationBar( + modifier = Modifier + .align(Alignment.BottomCenter) + .offset { + if (navigationBarHeight == 0.dp) { + IntOffset(x = 0, y = (bottomInset + NavigationBarHeight).roundToPx()) + } else { + val slideOffset = (bottomInset + NavigationBarHeight) * playerBottomSheetState.progress.coerceIn(0f, 1f) + val hideOffset = (bottomInset + NavigationBarHeight) * (1 - navigationBarHeight / NavigationBarHeight) + IntOffset( + x = 0, + y = (slideOffset + hideOffset).roundToPx() + ) + } + } + ) { + navigationItems.fastForEach { screen -> + NavigationBarItem( + selected = navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } == true, + icon = { + Icon( + painter = painterResource(screen.iconId), + contentDescription = null + ) + }, + label = { + Text( + text = stringResource(screen.titleId), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + + BottomSheetMenu( + state = LocalMenuState.current, + modifier = Modifier.align(Alignment.BottomCenter) + ) + + sharedSong?.let { song -> + playerConnection?.let { playerConnection -> + Dialog( + onDismissRequest = { sharedSong = null }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier.padding(24.dp), + shape = RoundedCornerShape(16.dp), + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + YouTubeSongMenu( + song = song, + navController = navController, + playerConnection = playerConnection, + onDismiss = { sharedSong = null } + ) + } + } + } + } + } + } + } + } + } + } + + @SuppressLint("ObsoleteSdkInt") + private fun setSystemBarAppearance(isDark: Boolean) { + WindowCompat.getInsetsController(window, window.decorView.rootView).apply { + isAppearanceLightStatusBars = !isDark + isAppearanceLightNavigationBars = !isDark + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + window.statusBarColor = (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + window.navigationBarColor = (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() + } + } +} + +val LocalDatabase = staticCompositionLocalOf { error("No database provided") } +val LocalPlayerConnection = staticCompositionLocalOf { error("No PlayerConnection provided") } +val LocalPlayerAwareWindowInsets = compositionLocalOf { error("No WindowInsets provided") } +val LocalDownloadUtil = staticCompositionLocalOf { error("No DownloadUtil provided") } diff --git a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt new file mode 100644 index 000000000..8c0426ae9 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt @@ -0,0 +1,29 @@ +package com.zionhuang.music.constants + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +const val CONTENT_TYPE_HEADER = 0 +const val CONTENT_TYPE_LIST = 1 +const val CONTENT_TYPE_SONG = 2 +const val CONTENT_TYPE_ARTIST = 3 +const val CONTENT_TYPE_ALBUM = 4 +const val CONTENT_TYPE_PLAYLIST = 5 + +val NavigationBarHeight = 80.dp +val MiniPlayerHeight = 64.dp +val QueuePeekHeight = 64.dp +val AppBarHeight = 64.dp + +val ListItemHeight = 64.dp +val SuggestionItemHeight = 56.dp +val SearchFilterHeight = 48.dp +val ListThumbnailSize = 48.dp +val GridThumbnailHeight = 128.dp +val AlbumThumbnailSize = 144.dp + +val ThumbnailCornerRadius = 6.dp + +val NavigationBarAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow) diff --git a/app/src/main/java/com/zionhuang/music/constants/Constants.kt b/app/src/main/java/com/zionhuang/music/constants/Constants.kt deleted file mode 100644 index 9bff59d51..000000000 --- a/app/src/main/java/com/zionhuang/music/constants/Constants.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.zionhuang.music.constants - -object Constants { - const val ACTION_SHOW_BOTTOM_SHEET = "show_bottom_sheet" - - const val BOTTOM_SHEET_STATE = "bottom_sheet_state" - const val QUEUE_SHEET_STATE = "queue_sheet_state" - - const val SONG_HEADER_ID = "song_header" - const val ARTIST_HEADER_ID = "artist_header" - const val ALBUM_HEADER_ID = "album_header" - const val PLAYLIST_HEADER_ID = "playlist_header" - const val PLAYLIST_SONG_HEADER_ID = "playlist_song_header" - const val TEXT_HEADER_ID = "text_header" - - const val LIKED_PLAYLIST_ID = "LP_LIKED" - const val DOWNLOADED_PLAYLIST_ID = "LP_DOWNLOADED" - - const val GITHUB_URL = "https://github.com/z-huang/InnerTune" - const val GITHUB_ISSUE_URL = "https://github.com/z-huang/InnerTune/issues" - - const val ERROR_INFO = "error_info" - - const val VISITOR_DATA = "visitor_data" - const val INNERTUBE_COOKIE = "innertube_cookie" - const val ACCOUNT_NAME = "account_name" - const val ACCOUNT_EMAIL = "account_email" -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/constants/MediaConstants.kt b/app/src/main/java/com/zionhuang/music/constants/MediaConstants.kt deleted file mode 100644 index a1b56d9a4..000000000 --- a/app/src/main/java/com/zionhuang/music/constants/MediaConstants.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.zionhuang.music.constants - -object MediaConstants { - const val EXTRA_MEDIA_METADATA = "media_metadata" - const val EXTRA_MEDIA_METADATA_ITEMS = "media_metadata_items" - const val EXTRA_SONG = "song" - const val EXTRA_ARTIST = "artist" - const val EXTRA_PLAYLIST = "playlist" - const val EXTRA_BLOCK = "block" - - const val STATE_NOT_DOWNLOADED = 0 - const val STATE_PREPARING = 1 - const val STATE_DOWNLOADING = 2 - const val STATE_DOWNLOADED = 3 -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt b/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt index e11505ed4..0f95dbf60 100644 --- a/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt +++ b/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt @@ -1,15 +1,11 @@ package com.zionhuang.music.constants +import android.os.Bundle +import androidx.media3.session.SessionCommand + object MediaSessionConstants { - const val ACTION_TOGGLE_LIBRARY = "action_toggle_library" - const val ACTION_ADD_TO_LIBRARY = "action_add_to_library" - const val ACTION_REMOVE_FROM_LIBRARY = "action_remove_from_library" - const val ACTION_TOGGLE_LIKE = "action_toggle_like" - const val ACTION_LIKE = "action_like" - const val ACTION_UNLIKE = "action_unlike" - const val ACTION_TOGGLE_SHUFFLE = "action_shuffle" - const val COMMAND_SEEK_TO_QUEUE_ITEM = "seek_to_queue_item" - const val COMMAND_PLAY_NEXT = "action_play_next" - const val COMMAND_ADD_TO_QUEUE = "action_add_to_queue" - const val EXTRA_QUEUE_INDEX = "index" -} \ No newline at end of file + const val ACTION_TOGGLE_LIBRARY = "TOGGLE_LIBRARY" + const val ACTION_TOGGLE_LIKE = "TOGGLE_LIKE" + val CommandToggleLibrary = SessionCommand(ACTION_TOGGLE_LIBRARY, Bundle.EMPTY) + val CommandToggleLike = SessionCommand(ACTION_TOGGLE_LIKE, Bundle.EMPTY) +} diff --git a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt new file mode 100644 index 000000000..ac29554e1 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -0,0 +1,291 @@ +package com.zionhuang.music.constants + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey + +val DynamicThemeKey = booleanPreferencesKey("dynamicTheme") +val DarkModeKey = stringPreferencesKey("darkMode") +val PureBlackKey = booleanPreferencesKey("pureBlack") +val DefaultOpenTabKey = stringPreferencesKey("defaultOpenTab") + +const val SYSTEM_DEFAULT = "SYSTEM_DEFAULT" +val ContentLanguageKey = stringPreferencesKey("contentLanguage") +val ContentCountryKey = stringPreferencesKey("contentCountry") +val ProxyEnabledKey = booleanPreferencesKey("proxyEnabled") +val ProxyUrlKey = stringPreferencesKey("proxyUrl") +val ProxyTypeKey = stringPreferencesKey("proxyType") + +val AudioQualityKey = stringPreferencesKey("audioQuality") + +enum class AudioQuality { + AUTO, HIGH, LOW +} + +val PersistentQueueKey = booleanPreferencesKey("persistentQueue") +val SkipSilenceKey = booleanPreferencesKey("skipSilence") +val AudioNormalizationKey = booleanPreferencesKey("audioNormalization") + +val MaxImageCacheSizeKey = intPreferencesKey("maxImageCacheSize") +val MaxSongCacheSizeKey = intPreferencesKey("maxSongCacheSize") + +val PauseListenHistoryKey = booleanPreferencesKey("pauseListenHistory") +val PauseSearchHistoryKey = booleanPreferencesKey("pauseSearchHistory") +val EnableKugouKey = booleanPreferencesKey("enableKugou") + +val SongSortTypeKey = stringPreferencesKey("songSortType") +val SongSortDescendingKey = booleanPreferencesKey("songSortDescending") +val DownloadedSongSortTypeKey = stringPreferencesKey("songSortType") +val DownloadedSongSortDescendingKey = booleanPreferencesKey("songSortDescending") +val PlaylistSongSortTypeKey = stringPreferencesKey("songSortType") +val PlaylistSongSortDescendingKey = booleanPreferencesKey("songSortDescending") +val ArtistSortTypeKey = stringPreferencesKey("artistSortType") +val ArtistSortDescendingKey = booleanPreferencesKey("artistSortDescending") +val AlbumSortTypeKey = stringPreferencesKey("albumSortType") +val AlbumSortDescendingKey = booleanPreferencesKey("albumSortDescending") +val PlaylistSortTypeKey = stringPreferencesKey("playlistSortType") +val PlaylistSortDescendingKey = booleanPreferencesKey("playlistSortDescending") +val ArtistSongSortTypeKey = stringPreferencesKey("artistSongSortType") +val ArtistSongSortDescendingKey = booleanPreferencesKey("artistSongSortDescending") + +val PlaylistEditLockKey = booleanPreferencesKey("playlistEditLock") + +enum class SongSortType { + CREATE_DATE, NAME, ARTIST +} + +enum class DownloadedSongSortType { + CREATE_DATE, NAME, ARTIST +} + +enum class PlaylistSongSortType { + CUSTOM, CREATE_DATE, NAME, ARTIST +} + +enum class ArtistSortType { + CREATE_DATE, NAME, SONG_COUNT +} + +enum class ArtistSongSortType { + CREATE_DATE, NAME +} + +enum class AlbumSortType { + CREATE_DATE, NAME, ARTIST, YEAR, SONG_COUNT, LENGTH +} + +enum class PlaylistSortType { + CREATE_DATE, NAME, SONG_COUNT +} + +val ShowLyricsKey = booleanPreferencesKey("showLyrics") +val LyricsTextPositionKey = stringPreferencesKey("lyricsTextPosition") + +val NavTabConfigKey = stringPreferencesKey("navTabConfig") + +val PlayerVolumeKey = floatPreferencesKey("playerVolume") +val RepeatModeKey = intPreferencesKey("repeatMode") + +val SearchSourceKey = stringPreferencesKey("searchSource") + +enum class SearchSource { + LOCAL, ONLINE +} + +val VisitorDataKey = stringPreferencesKey("visitorData") +val InnerTubeCookieKey = stringPreferencesKey("innerTubeCookie") +val AccountNameKey = stringPreferencesKey("accountName") +val AccountEmailKey = stringPreferencesKey("accountEmail") + +val LanguageCodeToName = mapOf( + "af" to "Afrikaans", + "az" to "Azərbaycan", + "id" to "Bahasa Indonesia", + "ms" to "Bahasa Malaysia", + "ca" to "Català", + "cs" to "Čeština", + "da" to "Dansk", + "de" to "Deutsch", + "et" to "Eesti", + "en-GB" to "English (UK)", + "en" to "English (US)", + "es" to "Español (España)", + "es-419" to "Español (Latinoamérica)", + "eu" to "Euskara", + "fil" to "Filipino", + "fr" to "Français", + "fr-CA" to "Français (Canada)", + "gl" to "Galego", + "hr" to "Hrvatski", + "zu" to "IsiZulu", + "is" to "Íslenska", + "it" to "Italiano", + "sw" to "Kiswahili", + "lt" to "Lietuvių", + "hu" to "Magyar", + "nl" to "Nederlands", + "no" to "Norsk", + "or" to "Odia", + "uz" to "O‘zbe", + "pl" to "Polski", + "pt-PT" to "Português", + "pt" to "Português (Brasil)", + "ro" to "Română", + "sq" to "Shqip", + "sk" to "Slovenčina", + "sl" to "Slovenščina", + "fi" to "Suomi", + "sv" to "Svenska", + "bo" to "Tibetan བོད་སྐད།", + "vi" to "Tiếng Việt", + "tr" to "Türkçe", + "bg" to "Български", + "ky" to "Кыргызча", + "kk" to "Қазақ Тілі", + "mk" to "Македонски", + "mn" to "Монгол", + "ru" to "Русский", + "sr" to "Српски", + "uk" to "Українська", + "el" to "Ελληνικά", + "hy" to "Հայերեն", + "iw" to "עברית", + "ur" to "اردو", + "ar" to "العربية", + "fa" to "فارسی", + "ne" to "नेपाली", + "mr" to "मराठी", + "hi" to "हिन्दी", + "bn" to "বাংলা", + "pa" to "ਪੰਜਾਬੀ", + "gu" to "ગુજરાતી", + "ta" to "தமிழ்", + "te" to "తెలుగు", + "kn" to "ಕನ್ನಡ", + "ml" to "മലയാളം", + "si" to "සිංහල", + "th" to "ภาษาไทย", + "lo" to "ລາວ", + "my" to "ဗမာ", + "ka" to "ქართული", + "am" to "አማርኛ", + "km" to "ខ្មែរ", + "zh-CN" to "中文 (简体)", + "zh-TW" to "中文 (繁體)", + "zh-HK" to "中文 (香港)", + "ja" to "日本語", + "ko" to "한국어", +) + +val CountryCodeToName = mapOf( + "DZ" to "Algeria", + "AR" to "Argentina", + "AU" to "Australia", + "AT" to "Austria", + "AZ" to "Azerbaijan", + "BH" to "Bahrain", + "BD" to "Bangladesh", + "BY" to "Belarus", + "BE" to "Belgium", + "BO" to "Bolivia", + "BA" to "Bosnia and Herzegovina", + "BR" to "Brazil", + "BG" to "Bulgaria", + "KH" to "Cambodia", + "CA" to "Canada", + "CL" to "Chile", + "HK" to "Hong Kong", + "CO" to "Colombia", + "CR" to "Costa Rica", + "HR" to "Croatia", + "CY" to "Cyprus", + "CZ" to "Czech Republic", + "DK" to "Denmark", + "DO" to "Dominican Republic", + "EC" to "Ecuador", + "EG" to "Egypt", + "SV" to "El Salvador", + "EE" to "Estonia", + "FI" to "Finland", + "FR" to "France", + "GE" to "Georgia", + "DE" to "Germany", + "GH" to "Ghana", + "GR" to "Greece", + "GT" to "Guatemala", + "HN" to "Honduras", + "HU" to "Hungary", + "IS" to "Iceland", + "IN" to "India", + "ID" to "Indonesia", + "IQ" to "Iraq", + "IE" to "Ireland", + "IL" to "Israel", + "IT" to "Italy", + "JM" to "Jamaica", + "JP" to "Japan", + "JO" to "Jordan", + "KZ" to "Kazakhstan", + "KE" to "Kenya", + "KR" to "South Korea", + "KW" to "Kuwait", + "LA" to "Lao", + "LV" to "Latvia", + "LB" to "Lebanon", + "LY" to "Libya", + "LI" to "Liechtenstein", + "LT" to "Lithuania", + "LU" to "Luxembourg", + "MK" to "Macedonia", + "MY" to "Malaysia", + "MT" to "Malta", + "MX" to "Mexico", + "ME" to "Montenegro", + "MA" to "Morocco", + "NP" to "Nepal", + "NL" to "Netherlands", + "NZ" to "New Zealand", + "NI" to "Nicaragua", + "NG" to "Nigeria", + "NO" to "Norway", + "OM" to "Oman", + "PK" to "Pakistan", + "PA" to "Panama", + "PG" to "Papua New Guinea", + "PY" to "Paraguay", + "PE" to "Peru", + "PH" to "Philippines", + "PL" to "Poland", + "PT" to "Portugal", + "PR" to "Puerto Rico", + "QA" to "Qatar", + "RO" to "Romania", + "RU" to "Russian Federation", + "SA" to "Saudi Arabia", + "SN" to "Senegal", + "RS" to "Serbia", + "SG" to "Singapore", + "SK" to "Slovakia", + "SI" to "Slovenia", + "ZA" to "South Africa", + "ES" to "Spain", + "LK" to "Sri Lanka", + "SE" to "Sweden", + "CH" to "Switzerland", + "TW" to "Taiwan", + "TZ" to "Tanzania", + "TH" to "Thailand", + "TN" to "Tunisia", + "TR" to "Turkey", + "UG" to "Uganda", + "UA" to "Ukraine", + "AE" to "United Arab Emirates", + "GB" to "United Kingdom", + "US" to "United States", + "UY" to "Uruguay", + "VE" to "Venezuela (Bolivarian Republic)", + "VN" to "Vietnam", + "YE" to "Yemen", + "ZW" to "Zimbabwe", +) diff --git a/app/src/main/java/com/zionhuang/music/db/Converters.kt b/app/src/main/java/com/zionhuang/music/db/Converters.kt index ad9470910..7b133bca6 100644 --- a/app/src/main/java/com/zionhuang/music/db/Converters.kt +++ b/app/src/main/java/com/zionhuang/music/db/Converters.kt @@ -7,10 +7,11 @@ import java.time.ZoneOffset class Converters { @TypeConverter - fun fromTimestamp(value: Long): LocalDateTime = - LocalDateTime.ofInstant(Instant.ofEpochMilli(value), ZoneOffset.UTC) + fun fromTimestamp(value: Long?): LocalDateTime? = + if (value != null) LocalDateTime.ofInstant(Instant.ofEpochMilli(value), ZoneOffset.UTC) + else null @TypeConverter - fun dateToTimestamp(date: LocalDateTime): Long = - date.atZone(ZoneOffset.UTC).toInstant().toEpochMilli() -} \ No newline at end of file + fun dateToTimestamp(date: LocalDateTime?): Long? = + date?.atZone(ZoneOffset.UTC)?.toInstant()?.toEpochMilli() +} diff --git a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt new file mode 100644 index 000000000..e387ae520 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -0,0 +1,498 @@ +package com.zionhuang.music.db + +import androidx.room.* +import androidx.sqlite.db.SupportSQLiteQuery +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.pages.AlbumPage +import com.zionhuang.innertube.pages.ArtistPage +import com.zionhuang.music.constants.* +import com.zionhuang.music.db.entities.* +import com.zionhuang.music.extensions.reversed +import com.zionhuang.music.extensions.toSQLiteQuery +import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.ui.utils.resize +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.time.LocalDateTime + +@Dao +interface DatabaseDao { + @Transaction + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY rowId") + fun songsByRowIdAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY inLibrary") + fun songsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY title") + fun songsByNameAsc(): Flow> + + fun songs(sortType: SongSortType, descending: Boolean) = + when (sortType) { + SongSortType.CREATE_DATE -> songsByCreateDateAsc() + SongSortType.NAME -> songsByNameAsc() + SongSortType.ARTIST -> songsByRowIdAsc().map { songs -> + songs.sortedBy { song -> + song.artists.joinToString(separator = "") { it.name } + } + } + }.map { it.reversed(descending) } + + @Transaction + @Query("SELECT * FROM song WHERE liked ORDER BY rowId") + fun likedSongsByRowIdAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE liked ORDER BY inLibrary") + fun likedSongsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE liked ORDER BY title") + fun likedSongsByNameAsc(): Flow> + + fun likedSongs(sortType: SongSortType, descending: Boolean) = + when (sortType) { + SongSortType.CREATE_DATE -> likedSongsByCreateDateAsc() + SongSortType.NAME -> likedSongsByNameAsc() + SongSortType.ARTIST -> likedSongsByRowIdAsc().map { songs -> + songs.sortedBy { song -> + song.artists.joinToString(separator = "") { it.name } + } + } + }.map { it.reversed(descending) } + + @Query("SELECT COUNT(1) FROM song WHERE liked") + fun likedSongsCount(): Flow + + @Transaction + @Query("SELECT song.* FROM song JOIN song_album_map ON song.id = song_album_map.songId WHERE song_album_map.albumId = :albumId") + fun albumSongs(albumId: String): Flow> + + @Transaction + @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId ORDER BY position") + fun playlistSongs(playlistId: String): Flow> + + @Transaction + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL ORDER BY inLibrary") + fun artistSongsByCreateDateAsc(artistId: String): Flow> + + @Transaction + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL ORDER BY title") + fun artistSongsByNameAsc(artistId: String): Flow> + + fun artistSongs(artistId: String, sortType: ArtistSongSortType, descending: Boolean) = + when (sortType) { + ArtistSongSortType.CREATE_DATE -> artistSongsByCreateDateAsc(artistId) + ArtistSongSortType.NAME -> artistSongsByNameAsc(artistId) + }.map { it.reversed(descending) } + + @Transaction + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL LIMIT :previewSize") + fun artistSongsPreview(artistId: String, previewSize: Int = 3): Flow> + + @Transaction + @Query( + """ + SELECT song.* + FROM (SELECT *, COUNT(1) AS referredCount + FROM related_song_map + GROUP BY relatedSongId) map + JOIN song ON song.id = map.relatedSongId + WHERE songId IN (SELECT songId + FROM (SELECT songId + FROM event + ORDER BY ROWID DESC + LIMIT 5) + UNION + SELECT songId + FROM (SELECT songId + FROM event + WHERE timestamp > :now - 86400000 * 7 + GROUP BY songId + ORDER BY SUM(playTime) DESC + LIMIT 5) + UNION + SELECT id + FROM (SELECT id + FROM song + ORDER BY totalPlayTime DESC + LIMIT 10)) + ORDER BY referredCount DESC + LIMIT 100 + """ + ) + fun quickPicks(now: Long = System.currentTimeMillis()): Flow> + + @Transaction + @Query("SELECT * FROM song ORDER BY totalPlayTime DESC LIMIT :limit") + fun mostPlayedSongs(limit: Int = 6): Flow> + + @Transaction + @Query( + """ + SELECT artist.*, + (SELECT COUNT(1) + FROM song_artist_map + JOIN song ON song_artist_map.songId = song.id + WHERE artistId = artist.id + AND song.inLibrary IS NOT NULL) AS songCount + FROM (SELECT artistId, SUM(playtime) AS totalPlaytime + FROM (SELECT *, (SELECT totalPlayTime FROM song WHERE id = songId) AS playtime + FROM song_artist_map) + GROUP BY artistId) + JOIN artist + ON artist.id = artistId + ORDER BY totalPlaytime DESC + LIMIT :limit + """ + ) + fun mostPlayedArtists(limit: Int = 6): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE id = :songId") + fun song(songId: String?): Flow + + @Transaction + @Query("SELECT * FROM song WHERE id IN (:songIds)") + fun songs(songIds: List): Flow> + + @Query("SELECT * FROM format WHERE id = :id") + fun format(id: String?): Flow + + @Query("SELECT * FROM lyrics WHERE id = :id") + fun lyrics(id: String?): Flow + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY rowId") + fun artistsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY name") + fun artistsByNameAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY songCount") + fun artistsBySongCountAsc(): Flow> + + fun artists(sortType: ArtistSortType, descending: Boolean) = + when (sortType) { + ArtistSortType.CREATE_DATE -> artistsByCreateDateAsc() + ArtistSortType.NAME -> artistsByNameAsc() + ArtistSortType.SONG_COUNT -> artistsBySongCountAsc() + }.map { it.reversed(descending) } + + @Query("SELECT * FROM artist WHERE id = :id") + fun artist(id: String): Flow + + @Transaction + @Query("SELECT * FROM album ORDER BY rowId") + fun albumsByRowIdAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album ORDER BY createDate") + fun albumsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album ORDER BY title") + fun albumsByNameAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album ORDER BY year") + fun albumsByYearAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album ORDER BY songCount") + fun albumsBySongCountAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album ORDER BY duration") + fun albumsByLengthAsc(): Flow> + + fun albums(sortType: AlbumSortType, descending: Boolean) = + when (sortType) { + AlbumSortType.CREATE_DATE -> albumsByCreateDateAsc() + AlbumSortType.NAME -> albumsByNameAsc() + AlbumSortType.ARTIST -> albumsByRowIdAsc().map { albums -> + albums.sortedBy { album -> + album.artists.joinToString(separator = "") { it.name } + } + } + + AlbumSortType.YEAR -> albumsByYearAsc() + AlbumSortType.SONG_COUNT -> albumsBySongCountAsc() + AlbumSortType.LENGTH -> albumsByLengthAsc() + }.map { it.reversed(descending) } + + @Transaction + @Query("SELECT * FROM album WHERE id = :id") + fun album(id: String): Flow + + @Transaction + @Query("SELECT * FROM album WHERE id = :albumId") + fun albumWithSongs(albumId: String): Flow + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist ORDER BY rowId") + fun playlistsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist ORDER BY name") + fun playlistsByNameAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist ORDER BY songCount") + fun playlistsBySongCountAsc(): Flow> + + fun playlists(sortType: PlaylistSortType, descending: Boolean) = + when (sortType) { + PlaylistSortType.CREATE_DATE -> playlistsByCreateDateAsc() + PlaylistSortType.NAME -> playlistsByNameAsc() + PlaylistSortType.SONG_COUNT -> playlistsBySongCountAsc() + }.map { it.reversed(descending) } + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE id = :playlistId") + fun playlist(playlistId: String): Flow + + @Transaction + @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND inLibrary IS NOT NULL LIMIT :previewSize") + fun searchSongs(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE name LIKE '%' || :query || '%' AND songCount > 0 LIMIT :previewSize") + fun searchArtists(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%' LIMIT :previewSize") + fun searchAlbums(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE name LIKE '%' || :query || '%' LIMIT :previewSize") + fun searchPlaylists(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Transaction + @Query("SELECT * FROM event ORDER BY rowId DESC") + fun events(): Flow> + + @Query("DELETE FROM event") + fun clearListenHistory() + + @Query("SELECT * FROM search_history WHERE `query` LIKE :query || '%' ORDER BY id DESC") + fun searchHistory(query: String = ""): Flow> + + @Query("DELETE FROM search_history") + fun clearSearchHistory() + + @Query("UPDATE song SET totalPlayTime = totalPlayTime + :playTime WHERE id = :songId") + fun incrementTotalPlayTime(songId: String, playTime: Long) + + @Query("UPDATE song SET inLibrary = :inLibrary WHERE id = :songId") + fun inLibrary(songId: String, inLibrary: LocalDateTime?) + + @Query("SELECT COUNT(1) FROM related_song_map WHERE songId = :songId LIMIT 1") + fun hasRelatedSongs(songId: String): Boolean + + @Query( + """ + UPDATE playlist_song_map SET position = + CASE + WHEN position < :fromPosition THEN position + 1 + WHEN position > :fromPosition THEN position - 1 + ELSE :toPosition + END + WHERE playlistId = :playlistId AND position BETWEEN MIN(:fromPosition, :toPosition) AND MAX(:fromPosition, :toPosition) + """ + ) + fun move(playlistId: String, fromPosition: Int, toPosition: Int) + + @Query("DELETE FROM playlist_song_map WHERE playlistId = :playlistId") + fun clearPlaylist(playlistId: String) + + @Query("SELECT * FROM artist WHERE name = :name") + fun artistByName(name: String): ArtistEntity? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(song: SongEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(artist: ArtistEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(album: AlbumEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(playlist: PlaylistEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: SongArtistMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: SongAlbumMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: AlbumArtistMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: PlaylistSongMap) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(searchHistory: SearchHistory) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(event: Event) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: RelatedSongMap) + + @Transaction + fun insert(mediaMetadata: MediaMetadata, block: (SongEntity) -> SongEntity = { it }) { + if (insert(mediaMetadata.toSongEntity().let(block)) == -1L) return + mediaMetadata.artists.forEachIndexed { index, artist -> + val artistId = artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.generateArtistId() + insert( + ArtistEntity( + id = artistId, + name = artist.name + ) + ) + insert( + SongArtistMap( + songId = mediaMetadata.id, + artistId = artistId, + position = index + ) + ) + } + } + + @Transaction + fun insert(albumPage: AlbumPage) { + if (insert(AlbumEntity( + id = albumPage.album.browseId, + title = albumPage.album.title, + year = albumPage.album.year, + thumbnailUrl = albumPage.album.thumbnail, + songCount = albumPage.songs.size, + duration = albumPage.songs.sumOf { it.duration ?: 0 } + )) == -1L + ) return + albumPage.songs.map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { index, song -> + SongAlbumMap( + songId = song.id, + albumId = albumPage.album.browseId, + index = index + ) + } + .forEach(::upsert) + albumPage.album.artists + ?.map { artist -> + ArtistEntity( + id = artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.generateArtistId(), + name = artist.name + ) + } + ?.onEach(::insert) + ?.mapIndexed { index, artist -> + AlbumArtistMap( + albumId = albumPage.album.browseId, + artistId = artist.id, + order = index + ) + } + ?.forEach(::insert) + } + + @Transaction + fun insert(albumWithSongs: AlbumWithSongs) { + if (insert(albumWithSongs.album) == -1L) return + albumWithSongs.songs.map(Song::toMediaMetadata).forEach(::insert) + albumWithSongs.songs.mapIndexed { index, song -> + SongAlbumMap( + songId = song.id, + albumId = albumWithSongs.album.id, + index = index + ) + }.forEach(::upsert) + albumWithSongs.artists.forEach(::insert) + albumWithSongs.artists.mapIndexed { index, artist -> + AlbumArtistMap( + albumId = albumWithSongs.album.id, + artistId = artist.id, + order = index + ) + }.forEach(::insert) + } + + @Update + fun update(song: SongEntity) + + @Update + fun update(artist: ArtistEntity) + + @Update + fun update(playlist: PlaylistEntity) + + @Update + fun update(map: PlaylistSongMap) + + fun update(artist: ArtistEntity, artistPage: ArtistPage) { + update( + artist.copy( + name = artistPage.artist.title, + thumbnailUrl = artistPage.artist.thumbnail.resize(544, 544), + lastUpdateTime = LocalDateTime.now() + ) + ) + } + + @Upsert + fun upsert(map: SongAlbumMap) + + @Upsert + fun upsert(lyrics: LyricsEntity) + + @Upsert + fun upsert(format: FormatEntity) + + @Delete + fun delete(song: SongEntity) + + @Delete + fun delete(artist: ArtistEntity) + + @Delete + fun delete(album: AlbumEntity) + + @Delete + fun delete(playlist: PlaylistEntity) + + @Delete + fun delete(playlistSongMap: PlaylistSongMap) + + @Delete + fun delete(lyrics: LyricsEntity) + + @Delete + fun delete(searchHistory: SearchHistory) + + @Delete + fun delete(event: Event) + + @Query("SELECT * FROM playlist_song_map WHERE songId = :songId") + fun playlistSongMaps(songId: String): List + + @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position >= :from ORDER BY position") + fun playlistSongMaps(playlistId: String, from: Int): List + + @RawQuery + fun raw(supportSQLiteQuery: SupportSQLiteQuery): Int + + fun checkpoint() { + raw("PRAGMA wal_checkpoint(FULL)".toSQLiteQuery()) + } +} diff --git a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt index 8049b9e21..42597f518 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -1,21 +1,43 @@ package com.zionhuang.music.db import android.content.Context -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT +import android.database.sqlite.SQLiteDatabase import androidx.core.content.contentValuesOf import androidx.room.* +import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -import com.zionhuang.music.db.daos.* +import androidx.sqlite.db.SupportSQLiteOpenHelper import com.zionhuang.music.db.entities.* -import com.zionhuang.music.db.entities.ArtistEntity.Companion.generateArtistId -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.generatePlaylistId import com.zionhuang.music.extensions.toSQLiteQuery import java.time.Instant +import java.time.LocalDateTime import java.time.ZoneOffset import java.util.* +class MusicDatabase( + private val delegate: InternalDatabase, +) : DatabaseDao by delegate.dao { + val openHelper: SupportSQLiteOpenHelper + get() = delegate.openHelper + + fun query(block: MusicDatabase.() -> Unit) = with(delegate) { + queryExecutor.execute { + block(this@MusicDatabase) + } + } + + fun transaction(block: MusicDatabase.() -> Unit) = with(delegate) { + transactionExecutor.execute { + runInTransaction { + block(this@MusicDatabase) + } + } + } + + fun close() = delegate.close() +} + @Database( entities = [ SongEntity::class, @@ -26,68 +48,76 @@ import java.util.* SongAlbumMap::class, AlbumArtistMap::class, PlaylistSongMap::class, - DownloadEntity::class, SearchHistory::class, FormatEntity::class, - LyricsEntity::class + LyricsEntity::class, + Event::class, + RelatedSongMap::class ], views = [ SortedSongArtistMap::class, + SortedSongAlbumMap::class, PlaylistSongMapPreview::class ], - version = 4, + version = 10, exportSchema = true, autoMigrations = [ AutoMigration(from = 2, to = 3), - AutoMigration(from = 3, to = 4) + AutoMigration(from = 3, to = 4), + AutoMigration(from = 4, to = 5), + AutoMigration(from = 5, to = 6, spec = Migration5To6::class), + AutoMigration(from = 6, to = 7, spec = Migration6To7::class), + AutoMigration(from = 7, to = 8, spec = Migration7To8::class), + AutoMigration(from = 8, to = 9), + AutoMigration(from = 9, to = 10, spec = Migration9To10::class) ] ) @TypeConverters(Converters::class) -abstract class MusicDatabase : RoomDatabase() { - abstract val songDao: SongDao - abstract val artistDao: ArtistDao - abstract val albumDao: AlbumDao - abstract val playlistDao: PlaylistDao - abstract val downloadDao: DownloadDao - abstract val searchHistoryDao: SearchHistoryDao - abstract val formatDao: FormatDao - abstract val lyricsDao: LyricsDao +abstract class InternalDatabase : RoomDatabase() { + abstract val dao: DatabaseDao companion object { const val DB_NAME = "song.db" - @Volatile - var INSTANCE: MusicDatabase? = null - - fun getInstance(context: Context): MusicDatabase { - if (INSTANCE == null) { - synchronized(MusicDatabase::class.java) { - if (INSTANCE == null) { - INSTANCE = Room.databaseBuilder(context, MusicDatabase::class.java, DB_NAME) - .addMigrations(MIGRATION_1_2) - .build() - } - } - } - return INSTANCE!! - } + fun newInstance(context: Context): MusicDatabase = + MusicDatabase( + delegate = Room.databaseBuilder(context, InternalDatabase::class.java, DB_NAME) + .addMigrations(MIGRATION_1_2) + .build() + ) } } val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { + data class OldSongEntity( + val id: String, + val title: String, + val duration: Int = -1, // in seconds + val thumbnailUrl: String? = null, + val albumId: String? = null, + val albumName: String? = null, + val liked: Boolean = false, + val totalPlayTime: Long = 0, // in milliseconds + val downloadState: Int = SongEntity.STATE_NOT_DOWNLOADED, + val createDate: LocalDateTime = LocalDateTime.now(), + val modifyDate: LocalDateTime = LocalDateTime.now(), + ) + val converters = Converters() val artistMap = mutableMapOf() val artists = mutableListOf() database.query("SELECT * FROM artist".toSQLiteQuery()).use { cursor -> while (cursor.moveToNext()) { val oldId = cursor.getInt(0) - val newId = generateArtistId() + val newId = ArtistEntity.generateArtistId() artistMap[oldId] = newId - artists.add(ArtistEntity( - id = newId, - name = cursor.getString(1) - )) + artists.add( + ArtistEntity( + id = newId, + name = cursor.getString(1) + ) + ) } } @@ -96,22 +126,26 @@ val MIGRATION_1_2 = object : Migration(1, 2) { database.query("SELECT * FROM playlist".toSQLiteQuery()).use { cursor -> while (cursor.moveToNext()) { val oldId = cursor.getInt(0) - val newId = generatePlaylistId() + val newId = PlaylistEntity.generatePlaylistId() playlistMap[oldId] = newId - playlists.add(PlaylistEntity( - id = newId, - name = cursor.getString(1) - )) + playlists.add( + PlaylistEntity( + id = newId, + name = cursor.getString(1) + ) + ) } } val playlistSongMaps = mutableListOf() database.query("SELECT * FROM playlist_song".toSQLiteQuery()).use { cursor -> while (cursor.moveToNext()) { - playlistSongMaps.add(PlaylistSongMap( - playlistId = playlistMap[cursor.getInt(1)]!!, - songId = cursor.getString(2), - position = cursor.getInt(3) - )) + playlistSongMaps.add( + PlaylistSongMap( + playlistId = playlistMap[cursor.getInt(1)]!!, + songId = cursor.getString(2), + position = cursor.getInt(3) + ) + ) } } // ensure we have continuous playlist song position @@ -123,24 +157,28 @@ val MIGRATION_1_2 = object : Migration(1, 2) { playlistSongCount[map.playlistId] = playlistSongCount[map.playlistId]!! + 1 } } - val songs = mutableListOf() + val songs = mutableListOf() val songArtistMaps = mutableListOf() database.query("SELECT * FROM song".toSQLiteQuery()).use { cursor -> while (cursor.moveToNext()) { val songId = cursor.getString(0) - songs.add(SongEntity( - id = songId, - title = cursor.getString(1), - duration = cursor.getInt(3), - liked = cursor.getInt(4) == 1, - createDate = Instant.ofEpochMilli(Date(cursor.getLong(8)).time).atZone(ZoneOffset.UTC).toLocalDateTime(), - modifyDate = Instant.ofEpochMilli(Date(cursor.getLong(9)).time).atZone(ZoneOffset.UTC).toLocalDateTime() - )) - songArtistMaps.add(SongArtistMap( - songId = songId, - artistId = artistMap[cursor.getInt(2)]!!, - position = 0 - )) + songs.add( + OldSongEntity( + id = songId, + title = cursor.getString(1), + duration = cursor.getInt(3), + liked = cursor.getInt(4) == 1, + createDate = Instant.ofEpochMilli(Date(cursor.getLong(8)).time).atZone(ZoneOffset.UTC).toLocalDateTime(), + modifyDate = Instant.ofEpochMilli(Date(cursor.getLong(9)).time).atZone(ZoneOffset.UTC).toLocalDateTime() + ) + ) + songArtistMaps.add( + SongArtistMap( + songId = songId, + artistId = artistMap[cursor.getInt(2)]!!, + position = 0 + ) + ) } } database.execSQL("DROP TABLE IF EXISTS song") @@ -169,63 +207,102 @@ val MIGRATION_1_2 = object : Migration(1, 2) { database.execSQL("CREATE VIEW `sorted_song_artist_map` AS SELECT * FROM song_artist_map ORDER BY position") database.execSQL("CREATE VIEW `playlist_song_map_preview` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position") artists.forEach { artist -> - database.insert("artist", CONFLICT_ABORT, contentValuesOf( - "id" to artist.id, - "name" to artist.name, - "createDate" to converters.dateToTimestamp(artist.createDate), - "lastUpdateTime" to converters.dateToTimestamp(artist.lastUpdateTime) - )) + database.insert( + "artist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "id" to artist.id, + "name" to artist.name, + "createDate" to converters.dateToTimestamp(artist.createDate), + "lastUpdateTime" to converters.dateToTimestamp(artist.lastUpdateTime) + ) + ) } songs.forEach { song -> - database.insert("song", CONFLICT_ABORT, contentValuesOf( - "id" to song.id, - "title" to song.title, - "duration" to song.duration, - "liked" to song.liked, - "totalPlayTime" to song.totalPlayTime, - "isTrash" to song.isTrash, - "download_state" to song.downloadState, - "create_date" to converters.dateToTimestamp(song.createDate), - "modify_date" to converters.dateToTimestamp(song.modifyDate) - )) + database.insert( + "song", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "id" to song.id, + "title" to song.title, + "duration" to song.duration, + "liked" to song.liked, + "totalPlayTime" to song.totalPlayTime, + "isTrash" to false, + "download_state" to song.downloadState, + "create_date" to converters.dateToTimestamp(song.createDate), + "modify_date" to converters.dateToTimestamp(song.modifyDate) + ) + ) } songArtistMaps.forEach { songArtistMap -> - database.insert("song_artist_map", CONFLICT_ABORT, contentValuesOf( - "songId" to songArtistMap.songId, - "artistId" to songArtistMap.artistId, - "position" to songArtistMap.position - )) + database.insert( + "song_artist_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "songId" to songArtistMap.songId, + "artistId" to songArtistMap.artistId, + "position" to songArtistMap.position + ) + ) } playlists.forEach { playlist -> - database.insert("playlist", CONFLICT_ABORT, contentValuesOf( - "id" to playlist.id, - "name" to playlist.name, - "createDate" to converters.dateToTimestamp(playlist.createDate), - "lastUpdateTime" to converters.dateToTimestamp(playlist.lastUpdateTime) - )) + database.insert( + "playlist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "id" to playlist.id, + "name" to playlist.name, + "createDate" to converters.dateToTimestamp(LocalDateTime.now()), + "lastUpdateTime" to converters.dateToTimestamp(LocalDateTime.now()) + ) + ) } playlistSongMaps.forEach { playlistSongMap -> - database.insert("playlist_song_map", CONFLICT_ABORT, contentValuesOf( - "playlistId" to playlistSongMap.playlistId, - "songId" to playlistSongMap.songId, - "position" to playlistSongMap.position - )) + database.insert( + "playlist_song_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "playlistId" to playlistSongMap.playlistId, + "songId" to playlistSongMap.songId, + "position" to playlistSongMap.position + ) + ) } } } -fun RoomDatabase.checkpoint() { - openHelper.writableDatabase.run { - query("PRAGMA journal_mode").use { cursor -> - if (cursor.moveToFirst()) { - when (cursor.getString(0).lowercase()) { - "wal" -> { - query("PRAGMA wal_checkpoint").use(Cursor::moveToFirst) - query("PRAGMA wal_checkpoint(TRUNCATE)").use(Cursor::moveToFirst) - query("PRAGMA wal_checkpoint").use(Cursor::moveToFirst) - } - } +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "isTrash"), + DeleteColumn(tableName = "playlist", columnName = "author"), + DeleteColumn(tableName = "playlist", columnName = "authorId"), + DeleteColumn(tableName = "playlist", columnName = "year"), + DeleteColumn(tableName = "playlist", columnName = "thumbnailUrl"), + DeleteColumn(tableName = "playlist", columnName = "createDate"), + DeleteColumn(tableName = "playlist", columnName = "lastUpdateTime") +) +@RenameColumn.Entries( + RenameColumn(tableName = "song", fromColumnName = "download_state", toColumnName = "downloadState"), + RenameColumn(tableName = "song", fromColumnName = "create_date", toColumnName = "createDate"), + RenameColumn(tableName = "song", fromColumnName = "modify_date", toColumnName = "modifyDate") +) +class Migration5To6 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.query("SELECT id FROM playlist WHERE id NOT LIKE 'LP%'").use { cursor -> + while (cursor.moveToNext()) { + db.execSQL("UPDATE playlist SET browseID = '${cursor.getString(0)}' WHERE id = '${cursor.getString(0)}'") } } } } + +class Migration6To7 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.query("SELECT id, createDate FROM song").use { cursor -> + while (cursor.moveToNext()) { + db.execSQL("UPDATE song SET inLibrary = ${cursor.getLong(1)} WHERE id = '${cursor.getString(0)}'") + } + } + } +} + +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "createDate"), + DeleteColumn(tableName = "song", columnName = "modifyDate") +) +class Migration7To8 : AutoMigrationSpec + +@DeleteTable.Entries( + DeleteTable(tableName = "download") +) +class Migration9To10 : AutoMigrationSpec \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt deleted file mode 100644 index 99ae49cc3..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.* -import androidx.sqlite.db.SupportSQLiteQuery -import com.zionhuang.music.db.entities.Album -import com.zionhuang.music.db.entities.AlbumArtistMap -import com.zionhuang.music.db.entities.AlbumEntity -import com.zionhuang.music.db.entities.SongAlbumMap -import com.zionhuang.music.extensions.toSQLiteQuery -import com.zionhuang.music.models.sortInfo.AlbumSortType -import com.zionhuang.music.models.sortInfo.ISortInfo -import kotlinx.coroutines.flow.Flow - -@Dao -interface AlbumDao { - @Transaction - @RawQuery(observedEntities = [AlbumEntity::class, AlbumArtistMap::class]) - fun getAlbumsAsFlow(query: SupportSQLiteQuery): Flow> - - fun getAllAlbumsAsFlow(sortInfo: ISortInfo) = getAlbumsAsFlow((QUERY_ALL_ALBUM + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM album") - suspend fun getAlbumCount(): Int - - @Transaction - @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%'") - fun searchAlbums(query: String): Flow> - - @Transaction - @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%' LIMIT :previewSize") - fun searchAlbumsPreview(query: String, previewSize: Int): Flow> - - @Query("SELECT * FROM album WHERE id = :id") - suspend fun getAlbumById(id: String): AlbumEntity? - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(album: AlbumEntity): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(albumArtistMap: AlbumArtistMap): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(songAlbumMaps: List): List - - @Update - suspend fun update(album: AlbumEntity) - - @Update - suspend fun update(songAlbumMaps: List) - - suspend fun upsert(songAlbumMaps: List) { - insert(songAlbumMaps) - .withIndex() - .mapNotNull { if (it.value == -1L) songAlbumMaps[it.index] else null } - .let { update(it) } - } - - @Delete - suspend fun delete(album: AlbumEntity) - - fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format( - when (sortInfo.type) { - AlbumSortType.CREATE_DATE -> "rowid" - AlbumSortType.NAME -> "album.title" - AlbumSortType.ARTIST -> throw IllegalArgumentException("Unexpected album sort type.") - AlbumSortType.YEAR -> "album.year" - AlbumSortType.SONG_COUNT -> "album.songCount" - AlbumSortType.LENGTH -> "album.duration" - }, - if (sortInfo.isDescending) "DESC" else "ASC" - ) - - companion object { - private const val QUERY_ALL_ALBUM = "SELECT * FROM album" - private const val QUERY_ORDER = " ORDER BY %s %s" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt deleted file mode 100644 index 399423f8f..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.* -import androidx.sqlite.db.SupportSQLiteQuery -import com.zionhuang.music.db.entities.Artist -import com.zionhuang.music.db.entities.ArtistEntity -import com.zionhuang.music.db.entities.SongArtistMap -import com.zionhuang.music.extensions.toSQLiteQuery -import com.zionhuang.music.models.sortInfo.ArtistSortType -import com.zionhuang.music.models.sortInfo.ISortInfo -import kotlinx.coroutines.flow.Flow - -@Dao -interface ArtistDao { - @Transaction - @RawQuery(observedEntities = [ArtistEntity::class, SongArtistMap::class]) - fun getArtistsAsFlow(query: SupportSQLiteQuery): Flow> - - fun getAllArtistsAsFlow(sortInfo: ISortInfo) = getArtistsAsFlow((QUERY_ALL_ARTIST + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM artist") - suspend fun getArtistCount(): Int - - @Query("SELECT * FROM artist WHERE id = :id") - suspend fun getArtistById(id: String): ArtistEntity? - - @Query("SELECT * FROM artist WHERE name = :name") - suspend fun getArtistByName(name: String): ArtistEntity? - - @Query("SELECT COUNT(*) FROM song_artist_map WHERE artistId = :id") - suspend fun getArtistSongCount(id: String): Int - - @Transaction - @Query("SELECT *, (SELECT COUNT(*) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist WHERE name LIKE '%' || :query || '%'") - fun searchArtists(query: String): Flow> - - @Transaction - @Query("SELECT *, (SELECT COUNT(*) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist WHERE name LIKE '%' || :query || '%' LIMIT :previewSize") - fun searchArtistsPreview(query: String, previewSize: Int): Flow> - - @Query("SELECT EXISTS(SELECT * FROM artist WHERE id = :id)") - suspend fun hasArtist(id: String): Boolean - - @Query("DELETE FROM song_artist_map WHERE songId = :songId") - suspend fun deleteSongArtists(songId: String) - - suspend fun deleteSongArtists(songIds: List) = songIds.forEach { - deleteSongArtists(it) - } - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(artist: ArtistEntity): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(songArtistMap: SongArtistMap): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(songArtistMaps: List) - - @Update - suspend fun update(artist: ArtistEntity) - - @Delete - suspend fun delete(artists: List) - - fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format( - when (sortInfo.type) { - ArtistSortType.CREATE_DATE -> "rowid" - ArtistSortType.NAME -> "artist.name" - else -> throw IllegalArgumentException("Unexpected artist sort type.") - }, - if (sortInfo.isDescending) "DESC" else "ASC" - ) - - companion object { - private const val QUERY_ALL_ARTIST = "SELECT *, (SELECT COUNT(*) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist" - private const val QUERY_ORDER = " ORDER BY %s %s" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/DownloadDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/DownloadDao.kt deleted file mode 100644 index 33f542433..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/DownloadDao.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.Query -import com.zionhuang.music.db.entities.DownloadEntity - -@Dao -interface DownloadDao { - @Query("SELECT * FROM download WHERE id = :downloadId") - suspend fun getDownloadEntity(downloadId: Long): DownloadEntity? - - @Insert - suspend fun insert(entity: DownloadEntity) - - @Query("DELETE FROM download WHERE id = :downloadId") - suspend fun delete(downloadId: Long) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/FormatDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/FormatDao.kt deleted file mode 100644 index cd1e37699..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/FormatDao.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.* -import com.zionhuang.music.db.entities.FormatEntity -import kotlinx.coroutines.flow.Flow - -@Dao -interface FormatDao { - @Query("SELECT * FROM format WHERE id = :id") - suspend fun getSongFormat(id: String?): FormatEntity? - - @Query("SELECT * FROM format WHERE id = :id") - fun getSongFormatAsFlow(id: String?): Flow - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(format: FormatEntity): Long - - @Update - suspend fun update(format: FormatEntity) - - suspend fun upsert(format: FormatEntity) { - if (insert(format) == -1L) { - update(format) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/LyricsDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/LyricsDao.kt deleted file mode 100644 index f8702ef6d..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/LyricsDao.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.* -import com.zionhuang.music.db.entities.LyricsEntity -import kotlinx.coroutines.flow.Flow - -@Dao -interface LyricsDao { - @Query("SELECT * FROM lyrics WHERE id = :id") - suspend fun getLyrics(id: String?): LyricsEntity? - - @Query("SELECT * FROM lyrics WHERE id = :id") - fun getLyricsAsFlow(id: String?): Flow - - @Query("SELECT EXISTS (SELECT 1 FROM lyrics WHERE id = :id)") - suspend fun hasLyrics(id: String): Boolean - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(lyrics: LyricsEntity): Long - - @Update - suspend fun update(lyrics: LyricsEntity) - - suspend fun upsert(lyrics: LyricsEntity) { - if (insert(lyrics) == -1L) { - update(lyrics) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt deleted file mode 100644 index 4211c7485..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.* -import androidx.sqlite.db.SupportSQLiteQuery -import com.zionhuang.music.db.entities.Playlist -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.db.entities.PlaylistSongMap -import com.zionhuang.music.extensions.toSQLiteQuery -import com.zionhuang.music.models.sortInfo.ISortInfo -import com.zionhuang.music.models.sortInfo.PlaylistSortType -import kotlinx.coroutines.flow.Flow - -@Dao -interface PlaylistDao { - @Transaction - @Query(QUERY_ALL_PLAYLIST) - suspend fun getAllPlaylistsAsList(): List - - @Transaction - @RawQuery(observedEntities = [PlaylistEntity::class, PlaylistSongMap::class]) - fun getPlaylistsAsFlow(query: SupportSQLiteQuery): Flow> - - fun getAllPlaylistsAsFlow(sortInfo: ISortInfo): Flow> = getPlaylistsAsFlow((QUERY_ALL_PLAYLIST + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM playlist") - suspend fun getPlaylistCount(): Int - - @Transaction - @Query("$QUERY_ALL_PLAYLIST WHERE id = :playlistId") - suspend fun getPlaylistById(playlistId: String): Playlist - - @Transaction - @Query("$QUERY_ALL_PLAYLIST WHERE name LIKE '%' || :query || '%'") - fun searchPlaylists(query: String): Flow> - - @Transaction - @Query("$QUERY_ALL_PLAYLIST WHERE name LIKE '%' || :query || '%' LIMIT :previewSize") - fun searchPlaylistsPreview(query: String, previewSize: Int): Flow> - - @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position = :position") - suspend fun getPlaylistSongMap(playlistId: String, position: Int): PlaylistSongMap? - - @Query("SELECT * FROM playlist_song_map WHERE songId IN (:songIds)") - suspend fun getPlaylistSongMaps(songIds: List): List - - @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position >= :from ORDER BY position") - suspend fun getPlaylistSongMaps(playlistId: String, from: Int): List - - @Query("UPDATE playlist_song_map SET position = position - 1 WHERE playlistId = :playlistId AND :from <= position") - suspend fun decrementSongPositions(playlistId: String, from: Int) - - @Query("UPDATE playlist_song_map SET position = position - 1 WHERE playlistId = :playlistId AND :from <= position AND position <= :to") - suspend fun decrementSongPositions(playlistId: String, from: Int, to: Int) - - @Query("UPDATE playlist_song_map SET position = position + 1 WHERE playlistId = :playlistId AND :from <= position AND position <= :to") - suspend fun incrementSongPositions(playlistId: String, from: Int, to: Int) - - suspend fun renewSongPositions(playlistId: String, from: Int) { - val maps = getPlaylistSongMaps(playlistId, from) - if (maps.isEmpty()) return - var position = if (from <= 0) 0 else maps[0].position - update(maps.map { it.copy(position = position++) }) - } - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(playlist: PlaylistEntity): Long - - @Insert - suspend fun insert(playlistSongMaps: List) - - @Update - suspend fun update(playlist: PlaylistEntity) - - @Update - suspend fun update(playlistSongMap: PlaylistSongMap) - - @Update - suspend fun update(playlistSongMaps: List) - - @Delete - suspend fun delete(playlists: List) - - - suspend fun deletePlaylistSong(playlistId: String, position: Int) = deletePlaylistSong(playlistId, listOf(position)) - - @Query("DELETE FROM playlist_song_map WHERE playlistId = :playlistId AND position IN (:position)") - suspend fun deletePlaylistSong(playlistId: String, position: List) - - @Query("SELECT max(position) FROM playlist_song_map WHERE playlistId = :playlistId") - suspend fun getPlaylistMaxId(playlistId: String): Int? - - fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format( - when (sortInfo.type) { - PlaylistSortType.CREATE_DATE -> "rowid" - PlaylistSortType.NAME -> "playlist.name" - PlaylistSortType.SONG_COUNT -> throw IllegalArgumentException("Unexpected playlist sort type.") - }, - if (sortInfo.isDescending) "DESC" else "ASC" - ) - - companion object { - private const val QUERY_ALL_PLAYLIST = "SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist" - private const val QUERY_ORDER = " ORDER BY %s %s" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt deleted file mode 100644 index c30f1046d..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.zionhuang.music.db.entities.SearchHistory - -@Dao -interface SearchHistoryDao { - @Query("SELECT * FROM search_history ORDER BY id DESC") - suspend fun getAllHistory(): List - - @Query("SELECT * FROM search_history WHERE `query` LIKE :query || '%' ORDER BY id DESC") - suspend fun getHistory(query: String): List - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(searchHistory: SearchHistory) - - @Query("DELETE FROM search_history WHERE `query` = :query") - suspend fun delete(query: String) - - @Query("DELETE FROM search_history") - suspend fun clearHistory() -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt deleted file mode 100644 index 8824e99d7..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.lifecycle.LiveData -import androidx.room.* -import androidx.sqlite.db.SupportSQLiteQuery -import com.zionhuang.music.constants.MediaConstants.STATE_DOWNLOADED -import com.zionhuang.music.db.entities.* -import com.zionhuang.music.extensions.toSQLiteQuery -import com.zionhuang.music.models.sortInfo.ISortInfo -import com.zionhuang.music.models.sortInfo.SongSortType -import kotlinx.coroutines.flow.Flow - -@Dao -interface SongDao { - @Transaction - @RawQuery(observedEntities = [SongEntity::class, ArtistEntity::class, AlbumEntity::class, SongArtistMap::class, SongAlbumMap::class]) - suspend fun getSongsAsList(query: SupportSQLiteQuery): List - - @Transaction - @RawQuery(observedEntities = [SongEntity::class, ArtistEntity::class, AlbumEntity::class, SongArtistMap::class, SongAlbumMap::class]) - fun getSongsAsFlow(query: SupportSQLiteQuery): Flow> - - fun getAllSongsAsFlow(sortInfo: ISortInfo): Flow> = getSongsAsFlow((QUERY_ALL_SONG + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM song WHERE NOT isTrash") - suspend fun getSongCount(): Int - - suspend fun getArtistSongsAsList(artistId: String, sortInfo: ISortInfo): List = getSongsAsList((QUERY_ARTIST_SONG.format(artistId) + getSortQuery(sortInfo)).toSQLiteQuery()) - fun getArtistSongsAsFlow(artistId: String, sortInfo: ISortInfo) = getSongsAsFlow((QUERY_ARTIST_SONG.format(artistId) + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM song_artist_map WHERE artistId = :artistId") - suspend fun getArtistSongCount(artistId: String): Int - - @Query("SELECT song.id FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND NOT song.isTrash LIMIT 5") - suspend fun getArtistSongsPreview(artistId: String): List - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT song.* FROM song JOIN song_album_map ON song.id = song_album_map.songId WHERE song_album_map.albumId = :albumId") - suspend fun getAlbumSongs(albumId: String): List - - @Transaction - @Query(QUERY_PLAYLIST_SONGS) - suspend fun getPlaylistSongsAsList(playlistId: String): List - - @Transaction - @Query(QUERY_PLAYLIST_SONGS) - fun getPlaylistSongsAsFlow(playlistId: String): Flow> - - fun getLikedSongs(sortInfo: ISortInfo) = getSongsAsFlow((QUERY_LIKED_SONG + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM song WHERE liked") - fun getLikedSongCount(): Flow - - fun getDownloadedSongsAsFlow(sortInfo: ISortInfo) = getSongsAsFlow((QUERY_DOWNLOADED_SONG + getSortQuery(sortInfo)).toSQLiteQuery()) - - suspend fun getDownloadedSongsAsList(sortInfo: ISortInfo) = getSongsAsList((QUERY_DOWNLOADED_SONG + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM song WHERE download_state = $STATE_DOWNLOADED") - fun getDownloadedSongCount(): Flow - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT * FROM song WHERE id = :songId") - suspend fun getSong(songId: String?): Song? - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT * FROM song WHERE id = :songId") - fun getSongAsLiveData(songId: String?): LiveData - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT * FROM song WHERE id = :songId") - fun getSongAsFlow(songId: String?): Flow - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND NOT isTrash") - fun searchSongs(query: String): Flow> - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT * FROM song WHERE download_state = $STATE_DOWNLOADED AND title LIKE '%' || :query || '%'") - fun searchDownloadedSongs(query: String): Flow> - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND NOT isTrash LIMIT :previewSize") - fun searchSongsPreview(query: String, previewSize: Int): Flow> - - @Query("SELECT EXISTS (SELECT 1 FROM song WHERE id = :songId)") - suspend fun hasSong(songId: String): Boolean - - @Query("SELECT EXISTS (SELECT 1 FROM song WHERE id = :songId)") - fun hasSongAsLiveData(songId: String): LiveData - - @Query("UPDATE song SET totalPlayTime = totalPlayTime + :playTime WHERE id = :songId") - suspend fun incrementSongTotalPlayTime(songId: String, playTime: Long) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(songs: List) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(song: SongEntity) - - @Update - suspend fun update(song: SongEntity) - - @Update - suspend fun update(songs: List) - - @Delete - suspend fun delete(songs: List) - - fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format( - when (sortInfo.type) { - SongSortType.CREATE_DATE -> "song.create_date" - SongSortType.NAME -> "song.title" - SongSortType.PLAY_TIME -> "song.totalPlayTime" - else -> throw IllegalArgumentException("Unexpected song sort type.") - }, - if (sortInfo.isDescending) "DESC" else "ASC" - ) - - companion object { - private const val QUERY_ORDER = " ORDER BY %s %s" - private const val QUERY_ALL_SONG = "SELECT * FROM song WHERE NOT isTrash" - private const val QUERY_ARTIST_SONG = - """ - SELECT song.* - FROM song_artist_map - JOIN song - ON song_artist_map.songId = song.id - WHERE artistId = "%s" AND NOT song.isTrash - """ - private const val QUERY_PLAYLIST_SONGS = - """ - SELECT song.*, playlist_song_map.position - FROM playlist_song_map - JOIN song - ON playlist_song_map.songId = song.id - WHERE playlistId = :playlistId AND NOT song.isTrash - ORDER BY playlist_song_map.position - """ - private const val QUERY_LIKED_SONG = "SELECT * FROM song WHERE liked" - private const val QUERY_DOWNLOADED_SONG = "SELECT * FROM song WHERE download_state = $STATE_DOWNLOADED" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Album.kt b/app/src/main/java/com/zionhuang/music/db/entities/Album.kt index 46e2fbc46..0a93a7c94 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/Album.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/Album.kt @@ -1,9 +1,11 @@ package com.zionhuang.music.db.entities +import androidx.compose.runtime.Immutable import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation +@Immutable data class Album( @Embedded val album: AlbumEntity, diff --git a/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt index f37cbb9fd..0f1678107 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt @@ -1,12 +1,11 @@ package com.zionhuang.music.db.entities -import android.os.Parcelable +import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey -import kotlinx.parcelize.Parcelize import java.time.LocalDateTime -@Parcelize +@Immutable @Entity(tableName = "album") data class AlbumEntity( @PrimaryKey val id: String, @@ -17,4 +16,4 @@ data class AlbumEntity( val duration: Int, val createDate: LocalDateTime = LocalDateTime.now(), val lastUpdateTime: LocalDateTime = LocalDateTime.now(), -) : Parcelable \ No newline at end of file +) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/entities/AlbumWithSongs.kt b/app/src/main/java/com/zionhuang/music/db/entities/AlbumWithSongs.kt new file mode 100644 index 000000000..6e64f031a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/AlbumWithSongs.kt @@ -0,0 +1,34 @@ +package com.zionhuang.music.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +@Immutable +data class AlbumWithSongs( + @Embedded + val album: AlbumEntity, + @Relation( + entity = ArtistEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = Junction( + value = AlbumArtistMap::class, + parentColumn = "albumId", + entityColumn = "artistId" + ) + ) + val artists: List, + @Relation( + entity = SongEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = Junction( + value = SortedSongAlbumMap::class, + parentColumn = "albumId", + entityColumn = "songId" + ) + ) + val songs: List, +) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt b/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt index 742d92e08..2eff0dcb8 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt @@ -1,7 +1,9 @@ package com.zionhuang.music.db.entities +import androidx.compose.runtime.Immutable import androidx.room.Embedded +@Immutable data class Artist( @Embedded val artist: ArtistEntity, diff --git a/app/src/main/java/com/zionhuang/music/db/entities/ArtistEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/ArtistEntity.kt index 95a4e8dc4..e054e2e22 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/ArtistEntity.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/ArtistEntity.kt @@ -1,13 +1,12 @@ package com.zionhuang.music.db.entities -import android.os.Parcelable +import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey -import kotlinx.parcelize.Parcelize import org.apache.commons.lang3.RandomStringUtils import java.time.LocalDateTime -@Parcelize +@Immutable @Entity(tableName = "artist") data class ArtistEntity( @PrimaryKey val id: String, @@ -17,7 +16,7 @@ data class ArtistEntity( val description: String? = null, val createDate: LocalDateTime = LocalDateTime.now(), val lastUpdateTime: LocalDateTime = LocalDateTime.now(), -) : Parcelable { +) { override fun toString(): String = name val isYouTubeArtist: Boolean diff --git a/app/src/main/java/com/zionhuang/music/db/entities/DownloadEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/DownloadEntity.kt deleted file mode 100644 index 7a0977053..000000000 --- a/app/src/main/java/com/zionhuang/music/db/entities/DownloadEntity.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.zionhuang.music.db.entities - -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity(tableName = "download") -data class DownloadEntity( - @PrimaryKey val id: Long, - val songId: String, -) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Event.kt b/app/src/main/java/com/zionhuang/music/db/entities/Event.kt new file mode 100644 index 000000000..0edb3f351 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/Event.kt @@ -0,0 +1,27 @@ +package com.zionhuang.music.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import java.time.LocalDateTime + +@Immutable +@Entity( + tableName = "event", + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class Event( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(index = true) val songId: String, + val timestamp: LocalDateTime, + val playTime: Long, +) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/EventWithSong.kt b/app/src/main/java/com/zionhuang/music/db/entities/EventWithSong.kt new file mode 100644 index 000000000..639d46aed --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/EventWithSong.kt @@ -0,0 +1,17 @@ +package com.zionhuang.music.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Relation + +@Immutable +data class EventWithSong( + @Embedded + val event: Event, + @Relation( + entity = SongEntity::class, + parentColumn = "songId", + entityColumn = "id" + ) + val song: Song, +) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/LocalItem.kt b/app/src/main/java/com/zionhuang/music/db/entities/LocalItem.kt index b4e474837..0407e17f0 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/LocalItem.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/LocalItem.kt @@ -1,70 +1,5 @@ package com.zionhuang.music.db.entities -import com.zionhuang.music.constants.Constants.ALBUM_HEADER_ID -import com.zionhuang.music.constants.Constants.ARTIST_HEADER_ID -import com.zionhuang.music.constants.Constants.DOWNLOADED_PLAYLIST_ID -import com.zionhuang.music.constants.Constants.LIKED_PLAYLIST_ID -import com.zionhuang.music.constants.Constants.PLAYLIST_HEADER_ID -import com.zionhuang.music.constants.Constants.PLAYLIST_SONG_HEADER_ID -import com.zionhuang.music.constants.Constants.SONG_HEADER_ID -import com.zionhuang.music.constants.Constants.TEXT_HEADER_ID -import com.zionhuang.music.models.sortInfo.* - -sealed class LocalBaseItem { +sealed class LocalItem { abstract val id: String -} - -sealed class LocalItem : LocalBaseItem() - -data class SongHeader( - val songCount: Int, - val sortInfo: SortInfo, -) : LocalBaseItem() { - override val id = SONG_HEADER_ID -} - -data class ArtistHeader( - val artistCount: Int, - val sortInfo: SortInfo, -) : LocalBaseItem() { - override val id = ARTIST_HEADER_ID -} - -data class AlbumHeader( - val albumCount: Int, - val sortInfo: SortInfo, -) : LocalBaseItem() { - override val id = ALBUM_HEADER_ID -} - -data class PlaylistHeader( - val playlistCount: Int, - val sortInfo: SortInfo, -) : LocalBaseItem() { - override val id = PLAYLIST_HEADER_ID -} - -data class LikedPlaylist( - val songCount: Int, -) : LocalBaseItem() { - override val id: String = LIKED_PLAYLIST_ID -} - -data class DownloadedPlaylist( - val songCount: Int, -) : LocalBaseItem() { - override val id: String = DOWNLOADED_PLAYLIST_ID -} - -data class PlaylistSongHeader( - val songCount: Int, - val length: Long, -) : LocalBaseItem() { - override val id: String = PLAYLIST_SONG_HEADER_ID -} - -data class TextHeader( - val title: String, -) : LocalBaseItem() { - override val id = TEXT_HEADER_ID } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt b/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt index f80acad05..f1ee6006c 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt @@ -1,9 +1,11 @@ package com.zionhuang.music.db.entities +import androidx.compose.runtime.Immutable import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation +@Immutable data class Playlist( @Embedded val playlist: PlaylistEntity, diff --git a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt index 132845b91..b2bf56092 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt @@ -1,31 +1,21 @@ package com.zionhuang.music.db.entities -import android.os.Parcelable +import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey -import kotlinx.parcelize.Parcelize import org.apache.commons.lang3.RandomStringUtils -import java.time.LocalDateTime -@Parcelize +@Immutable @Entity(tableName = "playlist") data class PlaylistEntity( - @PrimaryKey val id: String, + @PrimaryKey val id: String = generatePlaylistId(), val name: String, - val author: String? = null, - val authorId: String? = null, - val year: Int? = null, - val thumbnailUrl: String? = null, - val createDate: LocalDateTime = LocalDateTime.now(), - val lastUpdateTime: LocalDateTime = LocalDateTime.now(), -) : Parcelable { - val isLocalPlaylist: Boolean - get() = id.startsWith("LP") - - val isYouTubePlaylist: Boolean - get() = !isLocalPlaylist - + val browseId: String? = null, +) { companion object { + const val LIKED_PLAYLIST_ID = "LP_LIKED" + const val DOWNLOADED_PLAYLIST_ID = "LP_DOWNLOADED" + fun generatePlaylistId() = "LP" + RandomStringUtils.random(8, true, false) } } diff --git a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSong.kt b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSong.kt new file mode 100644 index 000000000..f2c7071b9 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSong.kt @@ -0,0 +1,14 @@ +package com.zionhuang.music.db.entities + +import androidx.room.Embedded +import androidx.room.Relation + +data class PlaylistSong( + @Embedded val map: PlaylistSongMap, + @Relation( + parentColumn = "songId", + entityColumn = "id", + entity = SongEntity::class + ) + val song: Song, +) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/RelatedSongMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/RelatedSongMap.kt new file mode 100644 index 000000000..3c347269f --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/RelatedSongMap.kt @@ -0,0 +1,29 @@ +package com.zionhuang.music.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "related_song_map", + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["relatedSongId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class RelatedSongMap( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val relatedSongId: String, +) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Song.kt b/app/src/main/java/com/zionhuang/music/db/entities/Song.kt index 05d403339..9e081aa46 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/Song.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/Song.kt @@ -1,12 +1,11 @@ package com.zionhuang.music.db.entities -import android.os.Parcelable +import androidx.compose.runtime.Immutable import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation -import kotlinx.parcelize.Parcelize -@Parcelize +@Immutable data class Song @JvmOverloads constructor( @Embedded val song: SongEntity, @Relation( @@ -31,8 +30,7 @@ data class Song @JvmOverloads constructor( ) ) val album: AlbumEntity? = null, - val position: Int? = -1, -) : LocalItem(), Parcelable { +) : LocalItem() { override val id: String get() = song.id -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt index 6a68a3e4b..c08b442be 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt @@ -24,5 +24,5 @@ import androidx.room.ForeignKey data class SongAlbumMap( @ColumnInfo(index = true) val songId: String, @ColumnInfo(index = true) val albumId: String, - val index: Int? = null, + val index: Int, ) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/SongEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/SongEntity.kt index ace58a1b2..dfed7f0b7 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/SongEntity.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/SongEntity.kt @@ -1,29 +1,35 @@ package com.zionhuang.music.db.entities -import android.os.Parcelable -import androidx.room.ColumnInfo +import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey -import com.zionhuang.music.constants.MediaConstants.STATE_NOT_DOWNLOADED -import kotlinx.parcelize.Parcelize import java.time.LocalDateTime -@Parcelize +@Immutable @Entity(tableName = "song") data class SongEntity( @PrimaryKey val id: String, val title: String, - val duration: Int = 0, // in seconds + val duration: Int = -1, // in seconds val thumbnailUrl: String? = null, val albumId: String? = null, val albumName: String? = null, val liked: Boolean = false, val totalPlayTime: Long = 0, // in milliseconds - val isTrash: Boolean = false, - @ColumnInfo(name = "download_state") val downloadState: Int = STATE_NOT_DOWNLOADED, - @ColumnInfo(name = "create_date") - val createDate: LocalDateTime = LocalDateTime.now(), - @ColumnInfo(name = "modify_date") - val modifyDate: LocalDateTime = LocalDateTime.now(), -) : Parcelable + val inLibrary: LocalDateTime? = null, +) { + fun toggleLike() = copy( + liked = !liked, + inLibrary = if (!liked) inLibrary ?: LocalDateTime.now() else inLibrary + ) + + fun toggleLibrary() = copy(inLibrary = if (inLibrary == null) LocalDateTime.now() else null) + + companion object { + const val STATE_NOT_DOWNLOADED = 0 + const val STATE_PREPARING = 1 + const val STATE_DOWNLOADING = 2 + const val STATE_DOWNLOADED = 3 + } +} diff --git a/app/src/main/java/com/zionhuang/music/db/entities/SortedSongAlbumMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/SortedSongAlbumMap.kt new file mode 100644 index 000000000..999f5f4ad --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/SortedSongAlbumMap.kt @@ -0,0 +1,13 @@ +package com.zionhuang.music.db.entities + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@DatabaseView( + viewName = "sorted_song_album_map", + value = "SELECT * FROM song_album_map ORDER BY `index`") +data class SortedSongAlbumMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val albumId: String, + val index: Int, +) diff --git a/app/src/main/java/com/zionhuang/music/di/AppModule.kt b/app/src/main/java/com/zionhuang/music/di/AppModule.kt new file mode 100644 index 000000000..622932a7b --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/di/AppModule.kt @@ -0,0 +1,61 @@ +package com.zionhuang.music.di + +import android.content.Context +import androidx.media3.database.DatabaseProvider +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.NoOpCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import com.zionhuang.music.constants.MaxSongCacheSizeKey +import com.zionhuang.music.db.InternalDatabase +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.utils.dataStore +import com.zionhuang.music.utils.get +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class PlayerCache + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DownloadCache + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Singleton + @Provides + fun provideDatabase(@ApplicationContext context: Context): MusicDatabase = + InternalDatabase.newInstance(context) + + @Singleton + @Provides + fun provideDatabaseProvider(@ApplicationContext context: Context): DatabaseProvider = + StandaloneDatabaseProvider(context) + + @Singleton + @Provides + @PlayerCache + fun providePlayerCache(@ApplicationContext context: Context, databaseProvider: DatabaseProvider): SimpleCache = + SimpleCache( + context.filesDir.resolve("exoplayer"), + when (val cacheSize = context.dataStore[MaxSongCacheSizeKey] ?: 1024) { + -1 -> NoOpCacheEvictor() + else -> LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L) + }, + databaseProvider + ) + + @Singleton + @Provides + @DownloadCache + fun provideDownloadCache(@ApplicationContext context: Context, databaseProvider: DatabaseProvider): SimpleCache = + SimpleCache(context.filesDir.resolve("download"), NoOpCacheEvictor(), databaseProvider) +} diff --git a/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt b/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt deleted file mode 100644 index 7bb930546..000000000 --- a/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.zionhuang.music.download - -import android.app.DownloadManager -import android.app.DownloadManager.* -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import androidx.core.content.getSystemService -import com.zionhuang.music.extensions.get -import com.zionhuang.music.repos.SongRepository -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -class DownloadBroadcastReceiver : BroadcastReceiver() { - @OptIn(DelicateCoroutinesApi::class) - override fun onReceive(context: Context, intent: Intent) { - val downloadManager = context.getSystemService()!! - val songRepository = SongRepository(context) - - when (intent.action) { - ACTION_DOWNLOAD_COMPLETE -> { - val id = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1) - if (id == -1L) return - downloadManager.query(Query().setFilterById(id)).use { cursor -> - val success = cursor.moveToFirst() && cursor.get(COLUMN_STATUS) == STATUS_SUCCESSFUL - GlobalScope.launch(IO) { - songRepository.onDownloadComplete(id, success) - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/ActivityExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ActivityExt.kt deleted file mode 100644 index 9346709af..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/ActivityExt.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.zionhuang.music.extensions - -import android.app.Activity -import android.view.WindowManager -import androidx.annotation.DimenRes -import androidx.annotation.IdRes -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment - -fun AppCompatActivity.replaceFragment(@IdRes id: Int, fragment: Fragment, tag: String? = null, addToBackStack: Boolean = false) { - supportFragmentManager.beginTransaction().apply { - replace(id, fragment, tag) - if (addToBackStack) { - addToBackStack(null) - } - commit() - } -} - -fun Activity.dip(@DimenRes id: Int): Int { - return resources.getDimensionPixelSize(id) -} - -fun AppCompatActivity.keepScreenOn(keepScreenOn: Boolean) { - if (keepScreenOn) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt b/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt deleted file mode 100644 index 65f5d0d2e..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.zionhuang.music.extensions - -import android.util.Log - -val Any.TAG: String - get() = javaClass.simpleName - -fun Any.logd(msg: String) { - Log.d(TAG, msg) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/BindingExt.kt b/app/src/main/java/com/zionhuang/music/extensions/BindingExt.kt deleted file mode 100644 index 58d13f3dc..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/BindingExt.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.zionhuang.music.extensions - -import android.content.Context -import androidx.viewbinding.ViewBinding - -val ViewBinding.context: Context - get() = root.context \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt deleted file mode 100644 index 992fc5977..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.zionhuang.music.extensions - -import android.content.Context -import android.content.ContextWrapper -import android.content.SharedPreferences -import android.graphics.Color -import androidx.annotation.AttrRes -import androidx.annotation.ColorInt -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.res.use -import androidx.lifecycle.LifecycleOwner -import androidx.preference.PreferenceManager -import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat -import com.zionhuang.music.models.toErrorInfo -import com.zionhuang.music.ui.activities.ErrorActivity -import com.zionhuang.music.utils.preference.Preference -import com.zionhuang.music.utils.preference.PreferenceLiveData -import kotlinx.coroutines.CoroutineExceptionHandler - -fun Context.getDensity(): Float = resources.displayMetrics.density - -tailrec fun Context?.getActivity(): AppCompatActivity? = when (this) { - is AppCompatActivity -> this - else -> (this as? ContextWrapper)?.baseContext?.getActivity() -} - -tailrec fun Context?.getLifeCycleOwner(): LifecycleOwner? = when (this) { - is LifecycleOwner -> this - else -> (this as? ContextWrapper)?.baseContext?.getLifeCycleOwner() -} - -@ColorInt -fun Context.resolveColor(@AttrRes attr: Int): Int = obtainStyledAttributes(intArrayOf(attr)).use { - it.getColor(0, Color.MAGENTA) -} - -fun Context.getAnimatedVectorDrawable(@DrawableRes id: Int): AnimatedVectorDrawableCompat = - AnimatedVectorDrawableCompat.create(this, id)!! - -val Context.sharedPreferences: SharedPreferences - get() = PreferenceManager.getDefaultSharedPreferences(this) - -fun Context.preference(@StringRes keyId: Int, defaultValue: T) = Preference(this, keyId, defaultValue) - -fun Context.preferenceLiveData(@StringRes keyId: Int, defaultValue: T) = PreferenceLiveData(this, keyId, defaultValue) -fun Context.preferenceLiveData(key: String, defaultValue: T) = PreferenceLiveData(this, key, defaultValue) - -fun Context.tryOrReport(block: () -> Unit) = try { - block() -} catch (e: Exception) { - ErrorActivity.openActivity(this, e.toErrorInfo()) -} - -val Context.exceptionHandler - get() = CoroutineExceptionHandler { _, throwable -> - ErrorActivity.openActivity(this, throwable.toErrorInfo()) - } diff --git a/app/src/main/java/com/zionhuang/music/extensions/CoroutineExt.kt b/app/src/main/java/com/zionhuang/music/extensions/CoroutineExt.kt new file mode 100644 index 000000000..20d884300 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/extensions/CoroutineExt.kt @@ -0,0 +1,21 @@ +package com.zionhuang.music.extensions + +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +fun Flow.collect(scope: CoroutineScope, action: suspend (value: T) -> Unit) { + scope.launch { + collect(action) + } +} + +fun Flow.collectLatest(scope: CoroutineScope, action: suspend (value: T) -> Unit) { + scope.launch { + collectLatest(action) + } +} + +val SilentHandler = CoroutineExceptionHandler { _, _ -> } diff --git a/app/src/main/java/com/zionhuang/music/extensions/CursorExt.kt b/app/src/main/java/com/zionhuang/music/extensions/CursorExt.kt deleted file mode 100644 index 532389486..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/CursorExt.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.zionhuang.music.extensions - -import android.database.Cursor - -fun Cursor.forEach(action: Cursor.() -> Unit) = use { - if (moveToFirst()) { - do { - action(this) - } while (moveToNext()) - } -} - -inline operator fun Cursor.get(name: String): T { - val index = getColumnIndexOrThrow(name) - return when (T::class) { - Short::class -> getShort(index) as T - Int::class -> getInt(index) as T - Long::class -> getLong(index) as T - Boolean::class -> (getInt(index) == 1) as T - String::class -> getString(index) as T - Float::class -> getFloat(index) as T - Double::class -> getDouble(index) as T - ByteArray::class -> getBlob(index) as T - else -> throw IllegalStateException("Unknown class ${T::class.java.simpleName}") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/EditTextExt.kt b/app/src/main/java/com/zionhuang/music/extensions/EditTextExt.kt deleted file mode 100644 index a653900e6..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/EditTextExt.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.zionhuang.music.extensions - -import android.widget.EditText -import androidx.core.widget.doOnTextChanged -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -fun EditText.getTextChangeFlow(): StateFlow { - val query = MutableStateFlow(text.toString()) - doOnTextChanged { text, _, _, _ -> - query.value = text.toString() - } - return query -} diff --git a/app/src/main/java/com/zionhuang/music/extensions/FragmentExt.kt b/app/src/main/java/com/zionhuang/music/extensions/FragmentExt.kt deleted file mode 100644 index 4a9ba0172..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/FragmentExt.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.zionhuang.music.extensions - -import android.content.Context -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.Fragment - -fun Fragment.requireAppCompatActivity(): AppCompatActivity = requireActivity() as AppCompatActivity - -val Fragment.sharedPreferences get() = requireContext().sharedPreferences - -fun DialogFragment.show(context: Context, tag: String? = null) { - context.getActivity()?.let { - show(it.supportFragmentManager, tag) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt index 2fb41d27c..c46f1513d 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt @@ -1,11 +1,9 @@ package com.zionhuang.music.extensions -@Suppress("UNCHECKED_CAST") -inline fun List<*>.castOrNull(): List? = if (all { it is T }) this as List else null +fun List.reversed(reversed: Boolean) = if (reversed) asReversed() else this -fun MutableList.swap(i: Int, j: Int): MutableList { - this[i] = this[j].also { this[j] = this[i] } +fun MutableList.move(fromIndex: Int, toIndex: Int): MutableList { + val item = removeAt(fromIndex) + add(toIndex, item) return this } - -fun List.reversed(reversed: Boolean) = if (reversed) reversed() else this \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/MediaCotrollerExt.kt b/app/src/main/java/com/zionhuang/music/extensions/MediaCotrollerExt.kt deleted file mode 100644 index f7fd86564..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/MediaCotrollerExt.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zionhuang.music.extensions - -import android.support.v4.media.session.MediaControllerCompat -import androidx.core.os.bundleOf -import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor -import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor.COMMAND_MOVE_QUEUE_ITEM -import com.zionhuang.music.constants.MediaSessionConstants.COMMAND_SEEK_TO_QUEUE_ITEM -import com.zionhuang.music.constants.MediaSessionConstants.EXTRA_QUEUE_INDEX - -fun MediaControllerCompat.moveQueueItem(from: Int, to: Int) = - sendCommand(COMMAND_MOVE_QUEUE_ITEM, bundleOf( - TimelineQueueEditor.EXTRA_FROM_INDEX to from, - TimelineQueueEditor.EXTRA_TO_INDEX to to - ), null) - -fun MediaControllerCompat.seekToQueueItem(index: Int) = - sendCommand(COMMAND_SEEK_TO_QUEUE_ITEM, bundleOf( - EXTRA_QUEUE_INDEX to index - ), null) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt b/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt index bdda724d8..50ae92c12 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt @@ -1,6 +1,8 @@ package com.zionhuang.music.extensions -import com.google.android.exoplayer2.MediaItem +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC import com.zionhuang.innertube.models.SongItem import com.zionhuang.music.db.entities.Song import com.zionhuang.music.models.MediaMetadata @@ -14,6 +16,15 @@ fun Song.toMediaItem() = MediaItem.Builder() .setUri(song.id) .setCustomCacheKey(song.id) .setTag(toMediaMetadata()) + .setMediaMetadata( + androidx.media3.common.MediaMetadata.Builder() + .setTitle(song.title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(song.thumbnailUrl?.toUri()) + .setMediaType(MEDIA_TYPE_MUSIC) + .build() + ) .build() fun SongItem.toMediaItem() = MediaItem.Builder() @@ -21,6 +32,15 @@ fun SongItem.toMediaItem() = MediaItem.Builder() .setUri(id) .setCustomCacheKey(id) .setTag(toMediaMetadata()) + .setMediaMetadata( + androidx.media3.common.MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(thumbnail.toUri()) + .setMediaType(MEDIA_TYPE_MUSIC) + .build() + ) .build() fun MediaMetadata.toMediaItem() = MediaItem.Builder() @@ -28,4 +48,13 @@ fun MediaMetadata.toMediaItem() = MediaItem.Builder() .setUri(id) .setCustomCacheKey(id) .setTag(this) + .setMediaMetadata( + androidx.media3.common.MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(thumbnailUrl?.toUri()) + .setMediaType(MEDIA_TYPE_MUSIC) + .build() + ) .build() \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/MediaSessionConnectorExt.kt b/app/src/main/java/com/zionhuang/music/extensions/MediaSessionConnectorExt.kt deleted file mode 100644 index 48b111139..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/MediaSessionConnectorExt.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.zionhuang.music.extensions - -import android.support.v4.media.MediaDescriptionCompat -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator - -fun MediaSessionConnector.setQueueNavigator(getMediaDescription: (player: Player, windowIndex: Int) -> MediaDescriptionCompat) = setQueueNavigator(object : TimelineQueueNavigator(mediaSession, Int.MAX_VALUE) { - override fun getMediaDescription(player: Player, windowIndex: Int) = getMediaDescription(player, windowIndex) - override fun onSkipToPrevious(player: Player) { - super.onSkipToPrevious(player) - if (player.playerError != null) { - player.prepare() - } - } - - override fun onSkipToNext(player: Player) { - super.onSkipToNext(player) - if (player.playerError != null) { - player.prepare() - } - } - - override fun onSkipToQueueItem(player: Player, id: Long) { - super.onSkipToQueueItem(player, id) - if (player.playerError != null) { - player.prepare() - } - } -}) diff --git a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt index a9f8d733a..f2785c09b 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt @@ -1,28 +1,61 @@ package com.zionhuang.music.extensions -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.Player +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Timeline import com.zionhuang.music.models.MediaMetadata +import java.util.ArrayDeque -fun Player.findMediaItemById(mediaId: String): MediaItem? { - for (i in 0 until mediaItemCount) { - val item = getMediaItemAt(i) - if (item.mediaId == mediaId) { - return item +fun Player.togglePlayPause() { + playWhenReady = !playWhenReady +} + +fun Player.getQueueWindows(): List { + val timeline = currentTimeline + if (timeline.isEmpty) { + return emptyList() + } + val queue = ArrayDeque() + val queueSize = timeline.windowCount + + val currentMediaItemIndex: Int = currentMediaItemIndex + queue.add(timeline.getWindow(currentMediaItemIndex, Timeline.Window())) + + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET) && queue.size < queueSize) { + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = timeline.getNextWindowIndex(lastMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(timeline.getWindow(lastMediaItemIndex, Timeline.Window())) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET && queue.size < queueSize) { + firstMediaItemIndex = timeline.getPreviousWindowIndex(firstMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.addFirst(timeline.getWindow(firstMediaItemIndex, Timeline.Window())) + } } } - return null + return queue.toList() } -fun Player.mediaItemIndexOf(mediaId: String?): Int? { - if (mediaId == null) return null - for (i in 0 until mediaItemCount) { - val item = getMediaItemAt(i) - if (item.mediaId == mediaId) { - return i +fun Player.getCurrentQueueIndex(): Int { + if (currentTimeline.isEmpty) { + return -1 + } + var index = 0 + var currentMediaItemIndex = currentMediaItemIndex + while (currentMediaItemIndex != C.INDEX_UNSET) { + currentMediaItemIndex = currentTimeline.getPreviousWindowIndex(currentMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled) + if (currentMediaItemIndex != C.INDEX_UNSET) { + index++ } } - return null + return index } val Player.currentMetadata: MediaMetadata? @@ -34,4 +67,13 @@ val Player.mediaItems: List get() = mediaItemCount override fun get(index: Int): MediaItem = getMediaItemAt(index) - } \ No newline at end of file + } + +fun Player.findNextMediaItemById(mediaId: String): MediaItem? { + for (i in currentMediaItemIndex until mediaItemCount) { + if (getMediaItemAt(i).mediaId == mediaId) { + return getMediaItemAt(i) + } + } + return null +} diff --git a/app/src/main/java/com/zionhuang/music/extensions/RecyclerViewExt.kt b/app/src/main/java/com/zionhuang/music/extensions/RecyclerViewExt.kt deleted file mode 100644 index e13b1d133..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/RecyclerViewExt.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.zionhuang.music.extensions - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import me.zhanghai.android.fastscroll.FastScroller -import me.zhanghai.android.fastscroll.FastScrollerBuilder - -typealias RecyclerViewItemClickListener = (position: Int, view: View) -> Unit - -fun RecyclerView.addOnClickListener(clickListener: RecyclerViewItemClickListener) { - this.addOnChildAttachStateChangeListener(object : RecyclerView.OnChildAttachStateChangeListener { - override fun onChildViewAttachedToWindow(view: View) { - view.setOnClickListener { - val position = this@addOnClickListener.getChildLayoutPosition(view) - if (position >= 0) { - clickListener.invoke(position, view) - } - } - } - - override fun onChildViewDetachedFromWindow(view: View) { - view.setOnClickListener(null) - } - }) -} - -fun RecyclerView.addOnLongClickListener(longClickListener: RecyclerViewItemClickListener) { - this.addOnChildAttachStateChangeListener(object : RecyclerView.OnChildAttachStateChangeListener { - override fun onChildViewAttachedToWindow(view: View) { - view.setOnLongClickListener { - val position = this@addOnLongClickListener.getChildLayoutPosition(view) - if (position >= 0) { - longClickListener.invoke(position, view) - - } - return@setOnLongClickListener true - } - } - - override fun onChildViewDetachedFromWindow(view: View) { - view.setOnLongClickListener(null) - } - }) -} - -fun RecyclerView.addFastScroller(applier: (FastScrollerBuilder.() -> Unit)): FastScroller = - FastScrollerBuilder(this).apply(applier).build() \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt b/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt deleted file mode 100644 index a8dbb4fb2..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.zionhuang.music.extensions - -import android.content.SharedPreferences -import androidx.core.content.edit -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.* -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -inline fun SharedPreferences.getSerializable(key: String, defaultValue: T): T? = getString(key, null)?.let { - Json.decodeFromString(it) as? T -} ?: defaultValue - -inline fun SharedPreferences.putSerializable(key: String, value: T) { - val jsonString = Json.encodeToString(value) - edit().putString(key, jsonString).apply() -} - -inline fun > SharedPreferences.getEnum(key: String, defaultValue: E): E = getString(key, null)?.let { - try { - enumValueOf(it) - } catch (e: IllegalArgumentException) { - null - } -} ?: defaultValue - -inline fun > SharedPreferences.putEnum(key: String, value: T) = edit().putString(key, value.name).apply() - -@Suppress("UNCHECKED_CAST") -fun SharedPreferences.get(key: String, defaultValue: T): T = when (defaultValue::class) { - Boolean::class -> getBoolean(key, defaultValue as Boolean) - Float::class -> getFloat(key, defaultValue as Float) - Int::class -> getInt(key, defaultValue as Int) - Long::class -> getLong(key, defaultValue as Long) - String::class -> getString(key, defaultValue as String) - else -> throw IllegalArgumentException("Unexpected type: ${defaultValue::class.java.name}") -} as T - -operator fun SharedPreferences.set(key: String, value: T) { - edit { - when (value::class) { - Boolean::class -> putBoolean(key, value as Boolean) - Float::class -> putFloat(key, value as Float) - Int::class -> putInt(key, value as Int) - Long::class -> putLong(key, value as Long) - String::class -> putString(key, value as String) - } - } -} - -val SharedPreferences.keyFlow: Flow - get() = callbackFlow { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key: String? -> - trySend(key) - } - registerOnSharedPreferenceChangeListener(listener) - awaitClose { - unregisterOnSharedPreferenceChangeListener(listener) - } - } - -fun SharedPreferences.booleanFlow(key: String, defaultValue: Boolean) = keyFlow - .filter { it == key || it == null } - .onStart { emit("init trigger") } - .map { getBoolean(key, defaultValue) } - .conflate() - -inline fun > SharedPreferences.enumFlow(key: String, defaultValue: E) = keyFlow - .filter { it == key || it == null } - .onStart { emit("init trigger") } - .map { getEnum(key, defaultValue) } - .conflate() diff --git a/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt b/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt index 5b640cadf..5e63570f5 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt @@ -1,18 +1,17 @@ package com.zionhuang.music.extensions import androidx.sqlite.db.SimpleSQLiteQuery -import com.google.gson.JsonElement -import com.google.gson.JsonParser -import com.google.gson.JsonSyntaxException import java.net.InetSocketAddress import java.net.InetSocketAddress.createUnresolved -@Throws(JsonSyntaxException::class) -fun String.parseJsonString(): JsonElement = JsonParser.parseString(this) +inline fun > String?.toEnum(defaultValue: T): T = + if (this == null) defaultValue + else try { + enumValueOf(this) + } catch (e: IllegalArgumentException) { + defaultValue + } -/** - * Database Extensions - */ fun String.toSQLiteQuery(): SimpleSQLiteQuery = SimpleSQLiteQuery(this) fun String.toInetSocketAddress(): InetSocketAddress { diff --git a/app/src/main/java/com/zionhuang/music/extensions/UtilExt.kt b/app/src/main/java/com/zionhuang/music/extensions/UtilExt.kt deleted file mode 100644 index 537ffd7cf..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/UtilExt.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zionhuang.music.extensions - -import android.app.Application -import com.zionhuang.music.App - -fun getApplication(): Application = App.INSTANCE - -fun tryOrNull(block: () -> T): T? = try { - block() -} catch (e: Exception) { - null -} diff --git a/app/src/main/java/com/zionhuang/music/extensions/ViewExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ViewExt.kt deleted file mode 100644 index bd8d1d3a3..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/ViewExt.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.zionhuang.music.extensions - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.app.Activity -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.LayoutRes -import androidx.core.view.isVisible -import androidx.databinding.DataBindingUtil -import androidx.databinding.ViewDataBinding - -fun ViewGroup.inflateWithBinding(@LayoutRes layoutRes: Int, attachToRoot: Boolean = false): T = - DataBindingUtil.inflate(LayoutInflater.from(context), layoutRes, this, attachToRoot) as T - -fun View.getActivity(): Activity? = context.getActivity() - -fun View.fadeIn(duration: Long) { - isVisible = true - alpha = 0f - animate() - .alpha(1f) - .setDuration(duration) - .setListener(null) -} - -fun View.fadeOut(duration: Long) { - isVisible = true - alpha = 1f - animate() - .alpha(0f) - .setDuration(duration) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - isVisible = false - } - }) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/ViewModelExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ViewModelExt.kt deleted file mode 100644 index f93f29791..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/ViewModelExt.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.zionhuang.music.extensions - -import androidx.annotation.StringRes -import androidx.lifecycle.AndroidViewModel -import com.zionhuang.music.utils.preference.Preference -import com.zionhuang.music.utils.preference.PreferenceLiveData - -fun AndroidViewModel.preference(@StringRes keyId: Int, defaultValue: T) = Preference(getApplication(), keyId, defaultValue) -fun AndroidViewModel.preferenceLiveData(@StringRes keyId: Int, defaultValue: T) = PreferenceLiveData(getApplication(), keyId, defaultValue) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/WindowInsetExt.kt b/app/src/main/java/com/zionhuang/music/extensions/WindowInsetExt.kt deleted file mode 100644 index b9bd0d67f..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/WindowInsetExt.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.zionhuang.music.extensions - -import android.os.Build -import android.view.WindowInsets -import androidx.annotation.RequiresApi -import androidx.core.graphics.Insets - - -val WindowInsets.systemBarInsetsCompat: Insets - get() = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> getCompatInsets(WindowInsets.Type.systemBars()) - else -> getSystemWindowCompatInsets() - } - -@RequiresApi(Build.VERSION_CODES.R) -fun WindowInsets.getCompatInsets(typeMask: Int) = Insets.toCompatInsets(getInsets(typeMask)) - -@Suppress("DEPRECATION") -fun WindowInsets.getSystemWindowCompatInsets() = Insets.of( - systemWindowInsetLeft, - systemWindowInsetTop, - systemWindowInsetRight, - systemWindowInsetBottom -) - -val WindowInsets.systemGestureInsetsCompat: Insets - get() = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { - Insets.max( - getCompatInsets(WindowInsets.Type.systemGestures()), - getCompatInsets(WindowInsets.Type.systemBars()) - ) - } - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> { - @Suppress("DEPRECATION") - Insets.max(getSystemGestureCompatInsets(), getSystemWindowCompatInsets()) - } - else -> getSystemWindowCompatInsets() - } - -@Suppress("DEPRECATION") -@RequiresApi(Build.VERSION_CODES.Q) -fun WindowInsets.getSystemGestureCompatInsets() = Insets.toCompatInsets(systemGestureInsets) diff --git a/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt b/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt deleted file mode 100644 index 46bdfcc0d..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.zionhuang.music.extensions - -import androidx.paging.PagingSource.LoadResult -import com.zionhuang.innertube.models.AlbumOrPlaylistHeader -import com.zionhuang.innertube.models.BrowseResult -import com.zionhuang.innertube.models.PlaylistItem -import com.zionhuang.innertube.models.SongItem -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.db.entities.SongEntity - -// the SongItem should be produced by get_queue endpoint to have detailed information -fun SongItem.toSongEntity() = SongEntity( - id = id, - title = title, - duration = duration!!, - thumbnailUrl = thumbnails.last().url, - albumId = album?.navigationEndpoint?.browseId, - albumName = album?.text -) - -fun PlaylistItem.toPlaylistEntity() = PlaylistEntity( - id = id, - name = title, - thumbnailUrl = thumbnails.last().url -) - -fun AlbumOrPlaylistHeader.toPlaylistEntity() = PlaylistEntity( - id = id, - name = name, - author = artists?.firstOrNull()?.text, - authorId = artists?.firstOrNull()?.navigationEndpoint?.browseEndpoint?.browseId, - year = year, - thumbnailUrl = thumbnails.lastOrNull()?.url -) - -fun BrowseResult.toPage() = LoadResult.Page( - data = items, - nextKey = continuations?.ifEmpty { null }, - prevKey = null -) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt index dfe68eb15..4c4438e3d 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt @@ -2,17 +2,19 @@ package com.zionhuang.music.lyrics import android.content.Context import com.zionhuang.kugou.KuGou -import com.zionhuang.music.R -import com.zionhuang.music.extensions.sharedPreferences +import com.zionhuang.music.constants.EnableKugouKey +import com.zionhuang.music.utils.dataStore +import com.zionhuang.music.utils.get object KuGouLyricsProvider : LyricsProvider { override val name = "Kugou" override fun isEnabled(context: Context): Boolean = - context.sharedPreferences.getBoolean(context.getString(R.string.pref_enable_kugou), true) + context.dataStore[EnableKugouKey] ?: true - override suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result = + override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result = KuGou.getLyrics(title, artist, duration) - override suspend fun getAllLyrics(id: String?, title: String, artist: String, duration: Int): Result> = - KuGou.getAllLyrics(title, artist, duration) -} \ No newline at end of file + override suspend fun getAllLyrics(id: String, title: String, artist: String, duration: Int, callback: (String) -> Unit) { + KuGou.getAllLyrics(title, artist, duration, callback) + } +} diff --git a/app/src/main/java/com/zionhuang/music/lyrics/LyricsEntry.kt b/app/src/main/java/com/zionhuang/music/lyrics/LyricsEntry.kt new file mode 100644 index 000000000..1e2d7b25b --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsEntry.kt @@ -0,0 +1,12 @@ +package com.zionhuang.music.lyrics + +data class LyricsEntry( + val time: Long, + val text: String, +) : Comparable { + override fun compareTo(other: LyricsEntry): Int = (time - other.time).toInt() + + companion object { + val HEAD_LYRICS_ENTRY = LyricsEntry(0L, "") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt b/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt new file mode 100644 index 000000000..9c1121aa2 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt @@ -0,0 +1,73 @@ +package com.zionhuang.music.lyrics + +import android.content.Context +import android.util.LruCache +import com.zionhuang.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND +import com.zionhuang.music.models.MediaMetadata +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class LyricsHelper @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val lyricsProviders = listOf(YouTubeSubtitleLyricsProvider, KuGouLyricsProvider, YouTubeLyricsProvider) + private val cache = LruCache>(MAX_CACHE_SIZE) + + suspend fun getLyrics(mediaMetadata: MediaMetadata): String { + val cached = cache.get(mediaMetadata.id)?.firstOrNull() + if (cached != null) { + return cached.lyrics + } + lyricsProviders.forEach { provider -> + if (provider.isEnabled(context)) { + provider.getLyrics( + mediaMetadata.id, + mediaMetadata.title, + mediaMetadata.artists.joinToString { it.name }, + mediaMetadata.duration + ).onSuccess { lyrics -> + return lyrics + }.onFailure { + it.printStackTrace() + } + } + } + return LYRICS_NOT_FOUND + } + + suspend fun getAllLyrics( + mediaId: String, + songTitle: String, + songArtists: String, + duration: Int, + callback: (LyricsResult) -> Unit, + ) { + val cacheKey = "$songArtists-$songTitle".replace(" ", "") + cache.get(cacheKey)?.let { results -> + results.forEach { + callback(it) + } + return + } + val allResult = mutableListOf() + lyricsProviders.forEach { provider -> + if (provider.isEnabled(context)) { + provider.getAllLyrics(mediaId, songTitle, songArtists, duration) { lyrics -> + val result = LyricsResult(provider.name, lyrics) + allResult += result + callback(result) + } + } + } + cache.put(cacheKey, allResult) + } + + companion object { + private const val MAX_CACHE_SIZE = 3 + } +} + +data class LyricsResult( + val providerName: String, + val lyrics: String, +) diff --git a/app/src/main/java/com/zionhuang/music/lyrics/LyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/LyricsProvider.kt index 30210fac7..d338710e9 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/LyricsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsProvider.kt @@ -5,6 +5,8 @@ import android.content.Context interface LyricsProvider { val name: String fun isEnabled(context: Context): Boolean - suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result - suspend fun getAllLyrics(id: String?, title: String, artist: String, duration: Int): Result> + suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result + suspend fun getAllLyrics(id: String, title: String, artist: String, duration: Int, callback: (String) -> Unit) { + getLyrics(id, title, artist, duration).onSuccess(callback) + } } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/lyrics/LyricsUtils.kt b/app/src/main/java/com/zionhuang/music/lyrics/LyricsUtils.kt similarity index 67% rename from app/src/main/java/com/zionhuang/music/utils/lyrics/LyricsUtils.kt rename to app/src/main/java/com/zionhuang/music/lyrics/LyricsUtils.kt index 2137c0d65..821df447c 100644 --- a/app/src/main/java/com/zionhuang/music/utils/lyrics/LyricsUtils.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsUtils.kt @@ -1,9 +1,9 @@ -package com.zionhuang.music.utils.lyrics +package com.zionhuang.music.lyrics -import android.text.format.DateUtils.MINUTE_IN_MILLIS -import android.text.format.DateUtils.SECOND_IN_MILLIS -import java.util.* +import android.text.format.DateUtils +import com.zionhuang.music.ui.component.animateScrollDuration +@Suppress("RegExpRedundantEscape") object LyricsUtils { private val LINE_REGEX = "((\\[\\d\\d:\\d\\d\\.\\d{2,3}\\])+)(.+)".toRegex() private val TIME_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\]".toRegex() @@ -31,16 +31,17 @@ object LyricsUtils { if (milString.length == 2) { mil *= 10 } - val time = min * MINUTE_IN_MILLIS + sec * SECOND_IN_MILLIS + mil + val time = min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil LyricsEntry(time, text) }.toList() } - fun formatTime(milli: Long): String { - val m = (milli / MINUTE_IN_MILLIS).toInt() - val s = (milli / SECOND_IN_MILLIS % 60).toInt() - val mm = String.format(Locale.getDefault(), "%02d", m) - val ss = String.format(Locale.getDefault(), "%02d", s) - return "$mm:$ss" + fun findCurrentLineIndex(lines: List, position: Long): Int { + for (index in lines.indices) { + if (lines[index].time >= position + animateScrollDuration) { + return index - 1 + } + } + return lines.lastIndex } } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt index 6daddf107..dd876f75c 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt @@ -7,13 +7,10 @@ import com.zionhuang.innertube.models.WatchEndpoint object YouTubeLyricsProvider : LyricsProvider { override val name = "YouTube Music" override fun isEnabled(context: Context) = true - override suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result = - YouTube.next(WatchEndpoint(videoId = id!!)).mapCatching { nextResult -> - YouTube.browse(nextResult.lyricsEndpoint ?: throw IllegalStateException("Lyrics endpoint not found")).getOrThrow() - }.mapCatching { browseResult -> - browseResult.lyrics ?: throw IllegalStateException("Lyrics unavailable") - } - - override suspend fun getAllLyrics(id: String?, title: String, artist: String, duration: Int): Result> = - getLyrics(id, title, artist, duration).map { listOf(it) } -} \ No newline at end of file + override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result = runCatching { + val nextResult = YouTube.next(WatchEndpoint(videoId = id)).getOrThrow() + YouTube.lyrics( + endpoint = nextResult.lyricsEndpoint ?: throw IllegalStateException("Lyrics endpoint not found") + ).getOrThrow() ?: throw IllegalStateException("Lyrics unavailable") + } +} diff --git a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeSubtitleLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeSubtitleLyricsProvider.kt new file mode 100644 index 000000000..781a88891 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeSubtitleLyricsProvider.kt @@ -0,0 +1,11 @@ +package com.zionhuang.music.lyrics + +import android.content.Context +import com.zionhuang.innertube.YouTube + +object YouTubeSubtitleLyricsProvider : LyricsProvider { + override val name = "YouTube Subtitle" + override fun isEnabled(context: Context) = true + override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result = + YouTube.transcript(id) +} diff --git a/app/src/main/java/com/zionhuang/music/models/DataWrapper.kt b/app/src/main/java/com/zionhuang/music/models/DataWrapper.kt deleted file mode 100644 index 3994807ae..000000000 --- a/app/src/main/java/com/zionhuang/music/models/DataWrapper.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.zionhuang.music.models - -import androidx.lifecycle.LiveData -import kotlinx.coroutines.flow.Flow - - -open class DataWrapper( - val getValue: () -> T = { throw UnsupportedOperationException() }, - val getValueAsync: suspend () -> T = { throw UnsupportedOperationException() }, - open val getFlow: () -> Flow = { throw UnsupportedOperationException() }, - open val getLiveData: () -> LiveData = { throw UnsupportedOperationException() }, -) { - val value: T get() = getValue() - val flow: Flow get() = getFlow() - val liveData: LiveData get() = getLiveData() -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/DownloadProgress.kt b/app/src/main/java/com/zionhuang/music/models/DownloadProgress.kt deleted file mode 100644 index 0ed48c72c..000000000 --- a/app/src/main/java/com/zionhuang/music/models/DownloadProgress.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.zionhuang.music.models - -data class DownloadProgress( - val status: Int, - val currentBytes: Int = -1, - val totalBytes: Int = -1, -) diff --git a/app/src/main/java/com/zionhuang/music/models/ErrorInfo.kt b/app/src/main/java/com/zionhuang/music/models/ErrorInfo.kt deleted file mode 100644 index a0e214c23..000000000 --- a/app/src/main/java/com/zionhuang/music/models/ErrorInfo.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.zionhuang.music.models - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class ErrorInfo( - val stackTrace: String, -) : Parcelable - -fun Throwable.toErrorInfo() = ErrorInfo( - stackTraceToString() -) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/ItemsPage.kt b/app/src/main/java/com/zionhuang/music/models/ItemsPage.kt new file mode 100644 index 000000000..a5dc16c4f --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/models/ItemsPage.kt @@ -0,0 +1,8 @@ +package com.zionhuang.music.models + +import com.zionhuang.innertube.models.YTItem + +data class ItemsPage( + val items: List, + val continuation: String?, +) diff --git a/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt b/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt deleted file mode 100644 index da92a33bc..000000000 --- a/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.zionhuang.music.models - -import androidx.lifecycle.LiveData -import kotlinx.coroutines.flow.Flow - -class ListWrapper( - val getList: suspend () -> List = { throw UnsupportedOperationException() }, - override val getFlow: () -> Flow> = { throw UnsupportedOperationException() }, - override val getLiveData: () -> LiveData> = { throw UnsupportedOperationException() }, -) : DataWrapper>(getValueAsync = getList) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt index 31421c166..f25a8c641 100644 --- a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt +++ b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt @@ -1,21 +1,12 @@ package com.zionhuang.music.models -import android.content.Context -import android.os.Parcelable -import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.MediaMetadataCompat.* -import androidx.core.net.toUri -import androidx.core.os.bundleOf +import androidx.compose.runtime.Immutable import com.zionhuang.innertube.models.SongItem -import com.zionhuang.music.db.entities.ArtistEntity -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.db.entities.SongEntity -import com.zionhuang.music.ui.bindings.resizeThumbnailUrl -import kotlinx.parcelize.Parcelize +import com.zionhuang.music.db.entities.* +import com.zionhuang.music.ui.utils.resize import java.io.Serializable -import kotlin.math.roundToInt -@Parcelize +@Immutable data class MediaMetadata( val id: String, val title: String, @@ -23,32 +14,16 @@ data class MediaMetadata( val duration: Int, val thumbnailUrl: String? = null, val album: Album? = null, -) : Parcelable, Serializable { - @Parcelize +) : Serializable { data class Artist( - val id: String, + val id: String?, val name: String, - ) : Parcelable, Serializable + ) : Serializable - @Parcelize data class Album( val id: String, val title: String, - val year: Int? = null, - ) : Parcelable, Serializable - - fun toMediaDescription(context: Context): MediaDescriptionCompat = builder - .setMediaId(id) - .setTitle(title) - .setSubtitle(artists.joinToString { it.name }) - .setDescription(artists.joinToString { it.name }) - .setIconUri(thumbnailUrl?.let { resizeThumbnailUrl(it, (512 * context.resources.displayMetrics.density).roundToInt(), null) }?.toUri()) - .setExtras(bundleOf( - METADATA_KEY_DURATION to duration * 1000L, - METADATA_KEY_ARTIST to artists.joinToString { it.name }, - METADATA_KEY_ALBUM to album?.title - )) - .build() + ) : Serializable fun toSongEntity() = SongEntity( id = id, @@ -58,10 +33,6 @@ data class MediaMetadata( albumId = album?.id, albumName = album?.title ) - - companion object { - private val builder = MediaDescriptionCompat.Builder() - } } fun Song.toMediaMetadata() = MediaMetadata( @@ -78,8 +49,7 @@ fun Song.toMediaMetadata() = MediaMetadata( album = album?.let { MediaMetadata.Album( id = it.id, - title = it.title, - year = it.year + title = it.title ) } ?: song.albumId?.let { albumId -> MediaMetadata.Album( @@ -94,17 +64,16 @@ fun SongItem.toMediaMetadata() = MediaMetadata( title = title, artists = artists.map { MediaMetadata.Artist( - id = it.navigationEndpoint?.browseEndpoint?.browseId ?: ArtistEntity.generateArtistId(), - name = it.text + id = it.id, + name = it.name ) }, - duration = duration ?: 0, - thumbnailUrl = thumbnails.lastOrNull()?.url, + duration = duration ?: -1, + thumbnailUrl = thumbnail.resize(544, 544), album = album?.let { MediaMetadata.Album( - id = it.navigationEndpoint.browseId, - title = it.text, - year = albumYear + id = it.id, + title = it.name ) } -) \ No newline at end of file +) diff --git a/app/src/main/java/com/zionhuang/music/playback/PersistQueue.kt b/app/src/main/java/com/zionhuang/music/models/PersistQueue.kt similarity index 68% rename from app/src/main/java/com/zionhuang/music/playback/PersistQueue.kt rename to app/src/main/java/com/zionhuang/music/models/PersistQueue.kt index 0f422a256..5f412bfee 100644 --- a/app/src/main/java/com/zionhuang/music/playback/PersistQueue.kt +++ b/app/src/main/java/com/zionhuang/music/models/PersistQueue.kt @@ -1,6 +1,5 @@ -package com.zionhuang.music.playback +package com.zionhuang.music.models -import com.zionhuang.music.models.MediaMetadata import java.io.Serializable data class PersistQueue( diff --git a/app/src/main/java/com/zionhuang/music/models/PlaybackStateData.kt b/app/src/main/java/com/zionhuang/music/models/PlaybackStateData.kt deleted file mode 100644 index e7ef85447..000000000 --- a/app/src/main/java/com/zionhuang/music/models/PlaybackStateData.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.zionhuang.music.models - -import android.support.v4.media.session.MediaControllerCompat -import android.support.v4.media.session.PlaybackStateCompat -import android.support.v4.media.session.PlaybackStateCompat.* - -data class PlaybackStateData( - @State val state: Int = STATE_NONE, - @ShuffleMode val shuffleMode: Int = SHUFFLE_MODE_NONE, - @RepeatMode val repeatMode: Int = REPEAT_MODE_NONE, - @Actions val actions: Long = 0, - val errorCode: Int = 0, - val errorMessage: String? = null, -) { - companion object { - fun from(mediaController: MediaControllerCompat, playbackState: PlaybackStateCompat) = PlaybackStateData( - playbackState.state, - mediaController.shuffleMode, - mediaController.repeatMode, - playbackState.actions, - playbackState.errorCode, - playbackState.errorMessage?.toString() - ) - } -} diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/AlbumSortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/AlbumSortInfoPreference.kt deleted file mode 100644 index f4dfe9f7f..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/AlbumSortInfoPreference.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -import android.content.Context -import com.zionhuang.music.R -import com.zionhuang.music.extensions.booleanFlow -import com.zionhuang.music.extensions.enumFlow -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.utils.preference.Preference -import com.zionhuang.music.utils.preference.enumPreference - -object AlbumSortInfoPreference : SortInfoPreference() { - val context: Context get() = getApplication() - override var type by enumPreference(context, R.string.pref_album_sort_type, AlbumSortType.CREATE_DATE) - override var isDescending by Preference(context, R.string.pref_album_sort_descending, true) - override val typeFlow = context.sharedPreferences.enumFlow(context.getString(R.string.pref_album_sort_type), AlbumSortType.CREATE_DATE) - override val isDescendingFlow = context.sharedPreferences.booleanFlow(context.getString(R.string.pref_album_sort_descending), true) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/ArtistSortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/ArtistSortInfoPreference.kt deleted file mode 100644 index cae0327d1..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/ArtistSortInfoPreference.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -import android.content.Context -import com.zionhuang.music.R -import com.zionhuang.music.extensions.booleanFlow -import com.zionhuang.music.extensions.enumFlow -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.utils.preference.Preference -import com.zionhuang.music.utils.preference.enumPreference - -object ArtistSortInfoPreference : SortInfoPreference() { - val context: Context get() = getApplication() - override var type: ArtistSortType by enumPreference(context, R.string.pref_artist_sort_type, ArtistSortType.CREATE_DATE) - override var isDescending by Preference(context, R.string.pref_artist_sort_descending, true) - override val typeFlow = context.sharedPreferences.enumFlow(context.getString(R.string.pref_artist_sort_type), ArtistSortType.CREATE_DATE) - override val isDescendingFlow = context.sharedPreferences.booleanFlow(context.getString(R.string.pref_artist_sort_descending), true) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/ISortInfo.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/ISortInfo.kt deleted file mode 100644 index fb5f55143..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/ISortInfo.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -interface ISortInfo { - val type: T - val isDescending: Boolean -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/PlaylistSortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/PlaylistSortInfoPreference.kt deleted file mode 100644 index b4bf67405..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/PlaylistSortInfoPreference.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -import android.content.Context -import com.zionhuang.music.R -import com.zionhuang.music.extensions.booleanFlow -import com.zionhuang.music.extensions.enumFlow -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.utils.preference.Preference -import com.zionhuang.music.utils.preference.enumPreference - -object PlaylistSortInfoPreference : SortInfoPreference() { - val context: Context get() = getApplication() - override var type by enumPreference(context, R.string.pref_playlist_sort_type, PlaylistSortType.CREATE_DATE) - override var isDescending by Preference(context, R.string.pref_playlist_sort_descending, true) - override val typeFlow = context.sharedPreferences.enumFlow(context.getString(R.string.pref_playlist_sort_type), PlaylistSortType.CREATE_DATE) - override val isDescendingFlow = context.sharedPreferences.booleanFlow(context.getString(R.string.pref_playlist_sort_descending), true) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/SongSortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/SongSortInfoPreference.kt deleted file mode 100644 index 038ed011d..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/SongSortInfoPreference.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -import android.content.Context -import com.zionhuang.music.R -import com.zionhuang.music.extensions.booleanFlow -import com.zionhuang.music.extensions.enumFlow -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.utils.preference.Preference -import com.zionhuang.music.utils.preference.enumPreference - -object SongSortInfoPreference : SortInfoPreference() { - val context: Context get() = getApplication() - override var type by enumPreference(context, R.string.pref_song_sort_type, SongSortType.CREATE_DATE) - override var isDescending by Preference(context, R.string.pref_song_sort_descending, true) - override val typeFlow = context.sharedPreferences.enumFlow(context.getString(R.string.pref_song_sort_type), SongSortType.CREATE_DATE) - override val isDescendingFlow = context.sharedPreferences.booleanFlow(context.getString(R.string.pref_song_sort_descending), true) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt deleted file mode 100644 index aa85cbe50..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -data class SortInfo( - override val type: T, - override val isDescending: Boolean, -) : ISortInfo - -interface SortType - -enum class SongSortType : SortType { - CREATE_DATE, NAME, ARTIST, PLAY_TIME -} - -enum class ArtistSortType : SortType { - CREATE_DATE, NAME, SONG_COUNT -} - -enum class AlbumSortType : SortType { - CREATE_DATE, NAME, ARTIST, YEAR, SONG_COUNT, LENGTH -} - -enum class PlaylistSortType : SortType { - CREATE_DATE, NAME, SONG_COUNT -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfoPreference.kt deleted file mode 100644 index 1c0655271..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfoPreference.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine - -abstract class SortInfoPreference : ISortInfo { - abstract override var type: T - abstract override var isDescending: Boolean - protected abstract val typeFlow: Flow - protected abstract val isDescendingFlow: Flow - - fun toggleIsDescending() { - isDescending = !isDescending - } - - val currentInfo: SortInfo - get() = SortInfo(type, isDescending) - - val flow: Flow> - get() = typeFlow.combine(isDescendingFlow) { type, isDescending -> - SortInfo(type, isDescending) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt b/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt index 7e9f5fd07..6043651de 100644 --- a/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt +++ b/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt @@ -9,27 +9,52 @@ import coil.request.Disposable import coil.request.ImageRequest class BitmapProvider(private val context: Context) { + var currentUrl: String? = null + var currentBitmap: Bitmap? = null private val map = LruCache(MAX_CACHE_SIZE) - private var disposable: Disposable? = null + var onBitmapChanged: (Bitmap?) -> Unit = {} + set(value) { + field = value + value(currentBitmap) + } fun load(url: String, callback: (Bitmap) -> Unit): Bitmap? { - val cache = map.get(url) + if (url == currentUrl) return map.get(url) + currentUrl = url disposable?.dispose() + val cache = map.get(url) if (cache == null) { - disposable = context.imageLoader.enqueue(ImageRequest.Builder(context) - .data(url) - .target(onSuccess = { drawable -> - val bitmap = (drawable as BitmapDrawable).bitmap - map.put(url, bitmap) - callback(bitmap) - }) - .build()) + disposable = context.imageLoader.enqueue( + ImageRequest.Builder(context) + .data(url) + .allowHardware(false) + .target( + onSuccess = { drawable -> + val bitmap = (drawable as BitmapDrawable).bitmap + map.put(url, bitmap) + callback(bitmap) + currentBitmap = bitmap + onBitmapChanged(bitmap) + } + ) + .build() + ) + } else { + currentBitmap = cache + onBitmapChanged(cache) } return cache } + fun clear() { + disposable?.dispose() + currentUrl = null + currentBitmap = null + onBitmapChanged(null) + } + companion object { - const val MAX_CACHE_SIZE = 10 + const val MAX_CACHE_SIZE = 15 } } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt b/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt new file mode 100644 index 000000000..1e90742b0 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt @@ -0,0 +1,148 @@ +package com.zionhuang.music.playback + +import android.content.Context +import android.net.ConnectivityManager +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import androidx.media3.common.PlaybackException +import androidx.media3.database.DatabaseProvider +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadManager +import androidx.media3.exoplayer.offline.DownloadNotificationHelper +import com.zionhuang.innertube.YouTube +import com.zionhuang.music.constants.AudioQuality +import com.zionhuang.music.constants.AudioQualityKey +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.FormatEntity +import com.zionhuang.music.di.DownloadCache +import com.zionhuang.music.di.PlayerCache +import com.zionhuang.music.utils.enumPreference +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import java.util.concurrent.Executor +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DownloadUtil @Inject constructor( + @ApplicationContext context: Context, + val database: MusicDatabase, + val databaseProvider: DatabaseProvider, + @DownloadCache val downloadCache: SimpleCache, + @PlayerCache val playerCache: SimpleCache, +) { + private val connectivityManager = context.getSystemService()!! + private val audioQuality by enumPreference(context, AudioQualityKey, AudioQuality.AUTO) + private val songUrlCache = HashMap>() + private val dataSourceFactory = ResolvingDataSource.Factory( + CacheDataSource.Factory() + .setCache(playerCache) + .setUpstreamDataSourceFactory( + OkHttpDataSource.Factory( + OkHttpClient.Builder() + .proxy(YouTube.proxy) + .build() + ) + ) + ) { dataSpec -> + val mediaId = dataSpec.key ?: error("No media id") + val length = if (dataSpec.length >= 0) dataSpec.length else 1 + + if (playerCache.isCached(mediaId, dataSpec.position, length)) { + return@Factory dataSpec + } + + songUrlCache[mediaId]?.takeIf { it.second < System.currentTimeMillis() }?.let { + return@Factory dataSpec.withUri(it.first.toUri()) + } + + val playedFormat = runBlocking(Dispatchers.IO) { database.format(mediaId).first() } + val playerResponse = runBlocking(Dispatchers.IO) { + YouTube.player(mediaId) + }.getOrThrow() + if (playerResponse.playabilityStatus.status != "OK") { + throw PlaybackException(playerResponse.playabilityStatus.reason, null, PlaybackException.ERROR_CODE_REMOTE_ERROR) + } + + val format = + if (playedFormat != null) { + playerResponse.streamingData?.adaptiveFormats?.find { it.itag == playedFormat.itag } + } else { + playerResponse.streamingData?.adaptiveFormats + ?.filter { it.isAudio } + ?.maxByOrNull { + it.bitrate * when (audioQuality) { + AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 + AudioQuality.HIGH -> 1 + AudioQuality.LOW -> -1 + } + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) // prefer opus stream + } + }!!.let { + // Specify range to avoid YouTube's throttling + it.copy(url = "${it.url}&range=0-${it.contentLength ?: 10000000}") + } + + database.query { + upsert( + FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + contentLength = format.contentLength!!, + loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb + ) + ) + } + + songUrlCache[mediaId] = format.url!! to playerResponse.streamingData!!.expiresInSeconds * 1000L + dataSpec.withUri(format.url!!.toUri()) + } + val downloadNotificationHelper = DownloadNotificationHelper(context, ExoDownloadService.CHANNEL_ID) + val downloadManager: DownloadManager = DownloadManager(context, databaseProvider, downloadCache, dataSourceFactory, Executor(Runnable::run)).apply { + maxParallelDownloads = 3 + addListener( + ExoDownloadService.TerminalStateNotificationHelper( + context = context, + notificationHelper = downloadNotificationHelper, + nextNotificationId = ExoDownloadService.NOTIFICATION_ID + 1 + ) + ) + } + val downloads = MutableStateFlow>(emptyMap()) + + fun getDownload(songId: String): Flow = downloads.map { it[songId] } + + init { + val result = mutableMapOf() + val cursor = downloadManager.downloadIndex.getDownloads() + while (cursor.moveToNext()) { + result[cursor.download.request.id] = cursor.download + } + downloads.value = result + downloadManager.addListener( + object : DownloadManager.Listener { + override fun onDownloadChanged(downloadManager: DownloadManager, download: Download, finalException: Exception?) { + downloads.update { map -> + map.toMutableMap().apply { + set(download.request.id, download) + } + } + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/ExoDownloadService.kt b/app/src/main/java/com/zionhuang/music/playback/ExoDownloadService.kt new file mode 100644 index 000000000..b8c8bdb47 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/playback/ExoDownloadService.kt @@ -0,0 +1,74 @@ +package com.zionhuang.music.playback + +import android.app.Notification +import android.content.Context +import androidx.media3.common.util.NotificationUtil +import androidx.media3.common.util.Util +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadManager +import androidx.media3.exoplayer.offline.DownloadNotificationHelper +import androidx.media3.exoplayer.offline.DownloadService +import androidx.media3.exoplayer.scheduler.PlatformScheduler +import androidx.media3.exoplayer.scheduler.Scheduler +import com.zionhuang.music.R +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + + +@AndroidEntryPoint +class ExoDownloadService : DownloadService( + NOTIFICATION_ID, + 1000L, + CHANNEL_ID, + R.string.download, + 0 +) { + @Inject + lateinit var downloadUtil: DownloadUtil + + override fun getDownloadManager() = downloadUtil.downloadManager + + override fun getScheduler(): Scheduler = PlatformScheduler(this, JOB_ID) + + override fun getForegroundNotification(downloads: MutableList, notMetRequirements: Int): Notification = + downloadUtil.downloadNotificationHelper.buildProgressNotification( + this, + R.drawable.download, + null, + if (downloads.size == 1) Util.fromUtf8Bytes(downloads[0].request.data) + else resources.getQuantityString(R.plurals.n_song, downloads.size, downloads.size), + downloads, + notMetRequirements + ) + + /** + * This helper will outlive the lifespan of a single instance of [ExoDownloadService] + */ + class TerminalStateNotificationHelper( + private val context: Context, + private val notificationHelper: DownloadNotificationHelper, + private var nextNotificationId: Int, + ) : DownloadManager.Listener { + override fun onDownloadChanged( + downloadManager: DownloadManager, + download: Download, + finalException: Exception?, + ) { + if (download.state == Download.STATE_FAILED) { + val notification = notificationHelper.buildDownloadFailedNotification( + context, + R.drawable.error, + null, + Util.fromUtf8Bytes(download.request.data) + ) + NotificationUtil.setNotification(context, nextNotificationId++, notification) + } + } + } + + companion object { + const val CHANNEL_ID = "download" + const val NOTIFICATION_ID = 1 + const val JOB_ID = 1 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/LifecycleMediaBrowserService.kt b/app/src/main/java/com/zionhuang/music/playback/LifecycleMediaBrowserService.kt deleted file mode 100644 index 68ad08116..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/LifecycleMediaBrowserService.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.zionhuang.music.playback - -import android.content.Intent -import android.os.IBinder -import androidx.annotation.CallSuper -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ServiceLifecycleDispatcher -import androidx.media.MediaBrowserServiceCompat - -abstract class LifecycleMediaBrowserService : MediaBrowserServiceCompat(), LifecycleOwner { - private val dispatcher by lazy { ServiceLifecycleDispatcher(this) } - - @CallSuper - override fun onCreate() { - dispatcher.onServicePreSuperOnCreate() - super.onCreate() - } - - @CallSuper - override fun onBind(intent: Intent): IBinder? { - dispatcher.onServicePreSuperOnBind() - return super.onBind(intent) - } - - @CallSuper - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - dispatcher.onServicePreSuperOnStart() - return super.onStartCommand(intent, flags, startId) - } - - @CallSuper - override fun onDestroy() { - dispatcher.onServicePreSuperOnDestroy() - super.onDestroy() - } - - override fun getLifecycle(): Lifecycle = dispatcher.lifecycle -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/MediaSessionConnection.kt b/app/src/main/java/com/zionhuang/music/playback/MediaSessionConnection.kt deleted file mode 100644 index 087309e59..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/MediaSessionConnection.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.zionhuang.music.playback - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import android.os.RemoteException -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaControllerCompat -import android.support.v4.media.session.MediaSessionCompat.QueueItem -import android.support.v4.media.session.PlaybackStateCompat -import com.zionhuang.music.playback.MusicService.MusicBinder -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -object MediaSessionConnection { - var mediaController: MediaControllerCompat? = null - private set - val transportControls: MediaControllerCompat.TransportControls? get() = mediaController?.transportControls - private val mediaControllerCallback = MediaControllerCallback() - - private val _isConnected = MutableStateFlow(false) - private val _playbackState = MutableStateFlow(null) - private val _mediaMetadata = MutableStateFlow(null) - private val _queueTitle = MutableStateFlow(null) - private val _queueItems = MutableStateFlow>(emptyList()) - - val isConnected: StateFlow = _isConnected - val playbackState: StateFlow = _playbackState - val mediaMetadata: StateFlow = _mediaMetadata - val queueTitle: StateFlow = _queueTitle - val queueItems: StateFlow> = _queueItems - - private var _binder: MusicBinder? = null - val binder: MusicBinder? get() = _binder - - private var serviceConnection: ServiceConnection? = null - - fun connect(context: Context) { - serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName, iBinder: IBinder) { - if (iBinder !is MusicBinder) return - _binder = iBinder - try { - mediaController = MediaControllerCompat(context, iBinder.sessionToken).apply { - registerCallback(mediaControllerCallback) - } - _isConnected.value = true - } catch (_: RemoteException) { - } - } - - override fun onServiceDisconnected(name: ComponentName) { - _binder = null - mediaController?.unregisterCallback(mediaControllerCallback) - _isConnected.value = false - } - }.also { - val intent = Intent(context, MusicService::class.java) - context.bindService(intent, it, Context.BIND_AUTO_CREATE) - } - } - - fun disconnect(context: Context) { - if (serviceConnection != null) { - context.unbindService(serviceConnection!!) - } - } - - private class MediaControllerCallback : MediaControllerCompat.Callback() { - override fun onPlaybackStateChanged(state: PlaybackStateCompat) { - _playbackState.value = state - } - - override fun onMetadataChanged(metadata: MediaMetadataCompat?) { - _mediaMetadata.value = metadata - // force update playback state - mediaController?.let { - _playbackState.value = it.playbackState - } - } - - override fun onQueueChanged(queue: List) { - _queueItems.value = queue - } - - override fun onQueueTitleChanged(title: CharSequence?) { - _queueTitle.value = title?.toString() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt index 2db79422a..b85b9bdb9 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -1,162 +1,949 @@ package com.zionhuang.music.playback -import android.app.Notification +import android.app.PendingIntent import android.content.ContentResolver +import android.content.Context import android.content.Intent +import android.database.SQLException +import android.media.audiofx.AudioEffect +import android.net.ConnectivityManager import android.net.Uri import android.os.Binder import android.os.Bundle -import android.os.IBinder -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_BROWSABLE -import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_PLAYABLE -import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.session.MediaSessionCompat import androidx.annotation.DrawableRes -import androidx.core.content.ContextCompat.startForegroundService +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.getSystemService import androidx.core.net.toUri -import androidx.lifecycle.lifecycleScope -import androidx.media.session.MediaButtonReceiver -import com.google.android.exoplayer2.ui.PlayerNotificationManager -import com.google.android.exoplayer2.upstream.cache.SimpleCache +import androidx.datastore.preferences.core.edit +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_ALBUM +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_ARTIST +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_PLAYLIST +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Player.EVENT_IS_PLAYING_CHANGED +import androidx.media3.common.Player.EVENT_PLAYBACK_STATE_CHANGED +import androidx.media3.common.Player.EVENT_PLAY_WHEN_READY_CHANGED +import androidx.media3.common.Player.EVENT_POSITION_DISCONTINUITY +import androidx.media3.common.Player.EVENT_TIMELINE_CHANGED +import androidx.media3.common.Player.STATE_ENDED +import androidx.media3.common.Player.STATE_IDLE +import androidx.media3.common.Timeline +import androidx.media3.database.DatabaseProvider +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.analytics.PlaybackStats +import androidx.media3.exoplayer.analytics.PlaybackStatsListener +import androidx.media3.exoplayer.audio.AudioCapabilities +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor +import androidx.media3.exoplayer.audio.SonicAudioProcessor +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.mkv.MatroskaExtractor +import androidx.media3.extractor.mp4.FragmentedMp4Extractor +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.innertube.models.response.PlayerResponse +import com.zionhuang.music.MainActivity import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.DOWNLOADED_PLAYLIST_ID -import com.zionhuang.music.constants.Constants.LIKED_PLAYLIST_ID -import com.zionhuang.music.models.sortInfo.AlbumSortInfoPreference -import com.zionhuang.music.models.sortInfo.ArtistSortInfoPreference -import com.zionhuang.music.models.sortInfo.PlaylistSortInfoPreference -import com.zionhuang.music.models.sortInfo.SongSortInfoPreference +import com.zionhuang.music.constants.AudioNormalizationKey +import com.zionhuang.music.constants.AudioQuality +import com.zionhuang.music.constants.AudioQualityKey +import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIBRARY +import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIKE +import com.zionhuang.music.constants.MediaSessionConstants.CommandToggleLibrary +import com.zionhuang.music.constants.MediaSessionConstants.CommandToggleLike +import com.zionhuang.music.constants.PauseListenHistoryKey +import com.zionhuang.music.constants.PersistentQueueKey +import com.zionhuang.music.constants.PlayerVolumeKey +import com.zionhuang.music.constants.RepeatModeKey +import com.zionhuang.music.constants.ShowLyricsKey +import com.zionhuang.music.constants.SkipSilenceKey +import com.zionhuang.music.constants.SongSortType +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.Event +import com.zionhuang.music.db.entities.FormatEntity +import com.zionhuang.music.db.entities.LyricsEntity +import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID +import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID +import com.zionhuang.music.db.entities.RelatedSongMap +import com.zionhuang.music.db.entities.Song +import com.zionhuang.music.di.DownloadCache +import com.zionhuang.music.di.PlayerCache +import com.zionhuang.music.extensions.SilentHandler +import com.zionhuang.music.extensions.collect +import com.zionhuang.music.extensions.collectLatest +import com.zionhuang.music.extensions.currentMetadata +import com.zionhuang.music.extensions.findNextMediaItemById +import com.zionhuang.music.extensions.mediaItems +import com.zionhuang.music.extensions.metadata +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.lyrics.LyricsHelper +import com.zionhuang.music.models.PersistQueue import com.zionhuang.music.models.toMediaMetadata -import com.zionhuang.music.repos.SongRepository +import com.zionhuang.music.playback.queues.EmptyQueue +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.playback.queues.Queue +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.utils.CoilBitmapLoader +import com.zionhuang.music.utils.dataStore +import com.zionhuang.music.utils.enumPreference +import com.zionhuang.music.utils.get +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.guava.future +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.time.LocalDateTime +import javax.inject.Inject +import kotlin.math.min +import kotlin.math.pow +import kotlin.time.Duration.Companion.minutes -class MusicService : LifecycleMediaBrowserService() { + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@AndroidEntryPoint +class MusicService : MediaLibraryService(), + Player.Listener, + PlaybackStatsListener.Callback, + MediaLibraryService.MediaLibrarySession.Callback { + @Inject + lateinit var database: MusicDatabase + + @Inject + lateinit var downloadUtil: DownloadUtil + + @Inject + lateinit var lyricsHelper: LyricsHelper + private val scope = CoroutineScope(Dispatchers.Main) + Job() private val binder = MusicBinder() - private val songRepository by lazy { SongRepository(this) } - private lateinit var songPlayer: SongPlayer + + private lateinit var connectivityManager: ConnectivityManager + val bitmapProvider = BitmapProvider(this) + + private val audioQuality by enumPreference(this, AudioQualityKey, AudioQuality.AUTO) + + private var currentQueue: Queue = EmptyQueue + var queueTitle: String? = null + + val currentMediaMetadata = MutableStateFlow(null) + private val currentSongFlow = currentMediaMetadata.flatMapLatest { mediaMetadata -> + database.song(mediaMetadata?.id) + } + private val currentFormat = currentMediaMetadata.flatMapLatest { mediaMetadata -> + database.format(mediaMetadata?.id) + } + private var currentSong: Song? = null + + private val normalizeFactor = MutableStateFlow(1f) + val playerVolume = MutableStateFlow(dataStore.get(PlayerVolumeKey, 1f).coerceIn(0f, 1f)) + + private var sleepTimerJob: Job? = null + var sleepTimerTriggerTime by mutableStateOf(-1L) + var pauseWhenSongEnd by mutableStateOf(false) + + @Inject + lateinit var databaseProvider: DatabaseProvider + + @Inject + @PlayerCache + lateinit var playerCache: SimpleCache + + @Inject + @DownloadCache + lateinit var downloadCache: SimpleCache + + lateinit var player: ExoPlayer + private lateinit var mediaSession: MediaLibrarySession override fun onCreate() { super.onCreate() - songPlayer = SongPlayer(this, lifecycleScope, object : PlayerNotificationManager.NotificationListener { - override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) { - stopForeground(STOP_FOREGROUND_REMOVE) + setMediaNotificationProvider( + DefaultMediaNotificationProvider(this, { NOTIFICATION_ID }, CHANNEL_ID, R.string.music_player) + .apply { + setSmallIcon(R.drawable.small_icon) + } + ) + player = ExoPlayer.Builder(this) + .setMediaSourceFactory(createMediaSourceFactory()) + .setRenderersFactory(createRenderersFactory()) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), true + ) + .setSeekBackIncrementMs(5000) + .setSeekForwardIncrementMs(5000) + .build() + .apply { + addListener(this@MusicService) + addAnalyticsListener(PlaybackStatsListener(false, this@MusicService)) + repeatMode = dataStore.get(RepeatModeKey, Player.REPEAT_MODE_OFF) + } + mediaSession = MediaLibrarySession.Builder(this, player, this) + .setSessionActivity( + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .setBitmapLoader(CoilBitmapLoader(this, scope)) + .build() + connectivityManager = getSystemService()!! + + combine(playerVolume, normalizeFactor) { playerVolume, normalizeFactor -> + playerVolume * normalizeFactor + }.collectLatest(scope) { + player.volume = it + } + + playerVolume.debounce(1000).collect(scope) { volume -> + dataStore.edit { settings -> + settings[PlayerVolumeKey] = volume + } + } + + currentSongFlow.collect(scope) { song -> + currentSong = song + updateNotification(song) + } + + combine( + currentMediaMetadata.distinctUntilChangedBy { it?.id }, + dataStore.data.map { it[ShowLyricsKey] ?: false }.distinctUntilChanged() + ) { mediaMetadata, showLyrics -> + mediaMetadata to showLyrics + }.collectLatest(scope) { (mediaMetadata, showLyrics) -> + if (showLyrics && mediaMetadata != null && database.lyrics(mediaMetadata.id).first() == null) { + val lyrics = lyricsHelper.getLyrics(mediaMetadata) + database.query { + upsert( + LyricsEntity( + id = mediaMetadata.id, + lyrics = lyrics + ) + ) + } + } + } + + dataStore.data + .map { it[SkipSilenceKey] ?: false } + .distinctUntilChanged() + .collectLatest(scope) { + player.skipSilenceEnabled = it + } + + combine( + currentFormat, + dataStore.data + .map { it[AudioNormalizationKey] ?: true } + .distinctUntilChanged() + ) { format, normalizeAudio -> + format to normalizeAudio + }.collectLatest(scope) { (format, normalizeAudio) -> + normalizeFactor.value = if (normalizeAudio && format?.loudnessDb != null) { + min(10f.pow(-format.loudnessDb.toFloat() / 20), 1f) + } else { + 1f + } + } + + if (dataStore.get(PersistentQueueKey, true)) { + runCatching { + filesDir.resolve(PERSISTENT_QUEUE_FILE).inputStream().use { fis -> + ObjectInputStream(fis).use { oos -> + oos.readObject() as PersistQueue + } + } + }.onSuccess { queue -> + playQueue( + queue = ListQueue( + title = queue.title, + items = queue.items.map { it.toMediaItem() }, + startIndex = queue.mediaItemIndex, + position = queue.position + ), + playWhenReady = false + ) + } + } + } + + private fun updateNotification(song: Song?) { + mediaSession.setCustomLayout( + listOf( + CommandButton.Builder() + .setDisplayName(getString(if (song?.song?.inLibrary != null) R.string.remove_from_library else R.string.add_to_library)) + .setIconResId(if (song?.song?.inLibrary != null) R.drawable.library_add_check else R.drawable.library_add) + .setSessionCommand(CommandToggleLibrary) + .setEnabled(song != null) + .build(), + CommandButton.Builder() + .setDisplayName(getString(if (currentSong?.song?.liked == true) R.string.action_remove_like else R.string.action_like)) + .setIconResId(if (song?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border) + .setSessionCommand(CommandToggleLike) + .setEnabled(song != null) + .build() + ) + ) + } + + private suspend fun recoverSong(mediaId: String, playerResponse: PlayerResponse? = null) { + val song = database.song(mediaId).first() + val mediaMetadata = withContext(Dispatchers.Main) { player.findNextMediaItemById(mediaId)?.metadata } ?: return + val duration = song?.song?.duration?.takeIf { it != -1 } + ?: mediaMetadata.duration.takeIf { it != -1 } + ?: (playerResponse ?: YouTube.player(mediaId).getOrNull())?.videoDetails?.lengthSeconds?.toInt() + ?: -1 + database.query { + if (song == null) insert(mediaMetadata.copy(duration = duration)) + else if (song.song.duration == -1) update(song.song.copy(duration = duration)) + } + if (!database.hasRelatedSongs(mediaId)) { + val relatedEndpoint = YouTube.next(WatchEndpoint(videoId = mediaId)).getOrNull()?.relatedEndpoint ?: return + val relatedPage = YouTube.related(relatedEndpoint).getOrNull() ?: return + database.query { + relatedPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .map { + RelatedSongMap( + songId = mediaId, + relatedSongId = it.id + ) + } + .forEach(::insert) + } + } + } + + private fun updateQueueTitle(title: String?) { + queueTitle = title + } + + fun playQueue(queue: Queue, playWhenReady: Boolean = true) { + currentQueue = queue + updateQueueTitle(null) + player.shuffleModeEnabled = false + if (queue.preloadItem != null) { + player.setMediaItem(queue.preloadItem!!.toMediaItem()) + player.prepare() + player.playWhenReady = playWhenReady + } + + scope.launch(SilentHandler) { + val initialStatus = withContext(Dispatchers.IO) { queue.getInitialStatus() } + if (queue.preloadItem != null && player.playbackState == STATE_IDLE) return@launch + initialStatus.title?.let { queueTitle -> + updateQueueTitle(queueTitle) + } + if (queue.preloadItem != null) { + player.addMediaItems(0, initialStatus.items.subList(0, initialStatus.mediaItemIndex)) + player.addMediaItems(initialStatus.items.subList(initialStatus.mediaItemIndex + 1, initialStatus.items.size)) + } else { + player.setMediaItems(initialStatus.items, if (initialStatus.mediaItemIndex > 0) initialStatus.mediaItemIndex else 0, initialStatus.position) + player.prepare() + player.playWhenReady = playWhenReady + } + } + } + + fun startRadioSeamlessly() { + val currentMediaMetadata = player.currentMetadata ?: return + if (player.currentMediaItemIndex > 0) player.removeMediaItems(0, player.currentMediaItemIndex) + if (player.currentMediaItemIndex < player.mediaItemCount - 1) player.removeMediaItems(player.currentMediaItemIndex + 1, player.mediaItemCount) + scope.launch(SilentHandler) { + val radioQueue = YouTubeQueue(endpoint = WatchEndpoint(videoId = currentMediaMetadata.id)) + val initialStatus = radioQueue.getInitialStatus() + initialStatus.title?.let { queueTitle -> + updateQueueTitle(queueTitle) + } + player.addMediaItems(initialStatus.items.drop(1)) + currentQueue = radioQueue + } + } + + fun playNext(items: List) { + player.addMediaItems(if (player.mediaItemCount == 0) 0 else player.currentMediaItemIndex + 1, items) + player.prepare() + } + + fun addToQueue(items: List) { + player.addMediaItems(items) + player.prepare() + } + + fun toggleLibrary() { + database.query { + currentSong?.let { + update(it.song.toggleLibrary()) + } + } + } + + fun toggleLike() { + database.query { + currentSong?.let { + update(it.song.toggleLike()) } + } + } + + fun setSleepTimer(minute: Int) { + sleepTimerJob?.cancel() + sleepTimerJob = null + if (minute == -1) { + pauseWhenSongEnd = true + } else { + sleepTimerTriggerTime = System.currentTimeMillis() + minute.minutes.inWholeMilliseconds + sleepTimerJob = scope.launch { + delay(minute.minutes) + player.pause() + sleepTimerTriggerTime = -1L + } + } + } + + fun clearSleepTimer() { + sleepTimerJob?.cancel() + sleepTimerJob = null + pauseWhenSongEnd = false + sleepTimerTriggerTime = -1L + } + + private fun openAudioEffectSession() { + sendBroadcast( + Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + } + ) + } + + private fun closeAudioEffectSession() { + sendBroadcast( + Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) + } + ) + } + + /** + * Auto load more + */ + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT && + player.playbackState != STATE_IDLE && + player.mediaItemCount - player.currentMediaItemIndex <= 5 && + currentQueue.hasNextPage() + ) { + scope.launch(SilentHandler) { + val mediaItems = currentQueue.nextPage() + if (player.playbackState != STATE_IDLE) { + player.addMediaItems(mediaItems) + } + } + } + if (mediaItem == null) { + bitmapProvider.clear() + } + if (pauseWhenSongEnd) { + pauseWhenSongEnd = false + player.pause() + } + } - override fun onNotificationPosted(notificationId: Int, notification: Notification, ongoing: Boolean) { - if (ongoing) { - startForegroundService(this@MusicService, Intent(this@MusicService, MusicService::class.java)) - startForeground(notificationId, notification) + override fun onPlaybackStateChanged(@Player.State playbackState: Int) { + if (playbackState == STATE_IDLE) { + currentQueue = EmptyQueue + player.shuffleModeEnabled = false + updateQueueTitle("") + } + if (playbackState == STATE_ENDED) { + if (pauseWhenSongEnd) { + pauseWhenSongEnd = false + player.pause() + } + } + } + + override fun onEvents(player: Player, events: Player.Events) { + if (events.containsAny(EVENT_PLAYBACK_STATE_CHANGED, EVENT_PLAY_WHEN_READY_CHANGED, EVENT_IS_PLAYING_CHANGED, EVENT_POSITION_DISCONTINUITY)) { + if (player.playbackState != STATE_ENDED && player.playWhenReady) { + openAudioEffectSession() + } else { + closeAudioEffectSession() + } + } + if (events.containsAny(EVENT_TIMELINE_CHANGED, EVENT_POSITION_DISCONTINUITY)) { + currentMediaMetadata.value = player.currentMetadata + } + } + + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + if (shuffleModeEnabled) { + // Always put current playing item at first + val shuffledIndices = IntArray(player.mediaItemCount) { it } + shuffledIndices.shuffle() + shuffledIndices[shuffledIndices.indexOf(player.currentMediaItemIndex)] = shuffledIndices[0] + shuffledIndices[0] = player.currentMediaItemIndex + player.setShuffleOrder(DefaultShuffleOrder(shuffledIndices, System.currentTimeMillis())) + } + } + + override fun onRepeatModeChanged(repeatMode: Int) { + scope.launch { + dataStore.edit { settings -> + settings[RepeatModeKey] = repeatMode + } + } + } + + private fun createOkHttpDataSourceFactory() = + OkHttpDataSource.Factory( + OkHttpClient.Builder() + .proxy(YouTube.proxy) + .build() + ) + + private fun createCacheDataSource(): CacheDataSource.Factory { + return CacheDataSource.Factory() + .setCache(downloadCache) + .setUpstreamDataSourceFactory( + CacheDataSource.Factory() + .setCache(playerCache) + .setUpstreamDataSourceFactory(DefaultDataSource.Factory(this, createOkHttpDataSourceFactory())) + ) + .setCacheWriteDataSinkFactory(null) + .setFlags(FLAG_IGNORE_CACHE_ON_ERROR) + } + + private fun createDataSourceFactory(): DataSource.Factory { + val songUrlCache = HashMap>() + return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> + val mediaId = dataSpec.key ?: error("No media id") + val length = if (dataSpec.length >= 0) dataSpec.length else 1 + + if (downloadCache.isCached(mediaId, dataSpec.position, length) || playerCache.isCached(mediaId, dataSpec.position, length)) { + scope.launch(Dispatchers.IO) { recoverSong(mediaId) } + return@Factory dataSpec + } + + songUrlCache[mediaId]?.takeIf { it.second < System.currentTimeMillis() }?.let { + scope.launch(Dispatchers.IO) { recoverSong(mediaId) } + return@Factory dataSpec.withUri(it.first.toUri()) + } + + // Check whether format exists so that users from older version can view format details + // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently + val playedFormat = runBlocking(Dispatchers.IO) { database.format(mediaId).first() } + val playerResponse = runBlocking(Dispatchers.IO) { + YouTube.player(mediaId) + }.getOrElse { throwable -> + when (throwable) { + is ConnectException, is UnknownHostException -> { + throw PlaybackException(getString(R.string.error_no_internet), throwable, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED) + } + + is SocketTimeoutException -> { + throw PlaybackException(getString(R.string.error_timeout), throwable, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT) + } + + else -> throw PlaybackException(getString(R.string.error_unknown), throwable, PlaybackException.ERROR_CODE_REMOTE_ERROR) + } + } + if (playerResponse.playabilityStatus.status != "OK") { + throw PlaybackException(playerResponse.playabilityStatus.reason, null, PlaybackException.ERROR_CODE_REMOTE_ERROR) + } + + val format = + if (playedFormat != null) { + playerResponse.streamingData?.adaptiveFormats?.find { + // Use itag to identify previously played format + it.itag == playedFormat.itag + } } else { - stopForeground(0) + playerResponse.streamingData?.adaptiveFormats + ?.filter { it.isAudio } + ?.maxByOrNull { + it.bitrate * when (audioQuality) { + AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 + AudioQuality.HIGH -> 1 + AudioQuality.LOW -> -1 + } + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) // prefer opus stream + } + } ?: throw PlaybackException(getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) + + database.query { + upsert( + FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + contentLength = format.contentLength!!, + loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb + ) + ) + } + scope.launch(Dispatchers.IO) { recoverSong(mediaId, playerResponse) } + + songUrlCache[mediaId] = format.url!! to playerResponse.streamingData!!.expiresInSeconds * 1000L + dataSpec.withUri(format.url!!.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) + } + } + + private fun createExtractorsFactory() = ExtractorsFactory { + arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) + } + + private fun createMediaSourceFactory() = DefaultMediaSourceFactory(createDataSourceFactory(), createExtractorsFactory()) + + private fun createRenderersFactory() = object : DefaultRenderersFactory(this) { + override fun buildAudioSink(context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, enableOffload: Boolean) = + DefaultAudioSink.Builder() + .setAudioCapabilities(AudioCapabilities.getCapabilities(context)) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setOffloadMode(if (enableOffload) DefaultAudioSink.OFFLOAD_MODE_ENABLED_GAPLESS_REQUIRED else DefaultAudioSink.OFFLOAD_MODE_DISABLED) + .setAudioProcessorChain( + DefaultAudioSink.DefaultAudioProcessorChain( + emptyArray(), + SilenceSkippingAudioProcessor(2_000_000, 20_000, 256), + SonicAudioProcessor() + ) + ) + .build() + } + + override fun onPlaybackStatsReady(eventTime: AnalyticsListener.EventTime, playbackStats: PlaybackStats) { + val mediaItem = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem + if (playbackStats.totalPlayTimeMs >= 30000 && !dataStore.get(PauseListenHistoryKey, false)) { + database.query { + incrementTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) + try { + insert( + Event( + songId = mediaItem.mediaId, + timestamp = LocalDateTime.now(), + playTime = playbackStats.totalPlayTimeMs + ) + ) + } catch (_: SQLException) { } } - }) - sessionToken = songPlayer.mediaSession.sessionToken + } } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - MediaButtonReceiver.handleIntent(songPlayer.mediaSession, intent) - return START_STICKY + private fun saveQueueToDisk() { + if (player.playbackState == STATE_IDLE) { + filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() + return + } + val persistQueue = PersistQueue( + title = queueTitle, + items = player.mediaItems.mapNotNull { it.metadata }, + mediaItemIndex = player.currentMediaItemIndex, + position = player.currentPosition + ) + runCatching { + filesDir.resolve(PERSISTENT_QUEUE_FILE).outputStream().use { fos -> + ObjectOutputStream(fos).use { oos -> + oos.writeObject(persistQueue) + } + } + }.onFailure { + it.printStackTrace() + } } override fun onDestroy() { - songPlayer.onDestroy() + if (dataStore.get(PersistentQueueKey, true)) { + saveQueueToDisk() + } + mediaSession.release() + player.removeListener(this) + player.release() + playerCache.release() super.onDestroy() } - override fun onBind(intent: Intent): IBinder? { - val superBinder = super.onBind(intent) - return when (intent.action) { - SERVICE_INTERFACE -> superBinder - else -> binder - } - } + override fun onBind(intent: Intent?) = super.onBind(intent) ?: binder override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) stopSelf() } - inner class MusicBinder : Binder() { - val sessionToken: MediaSessionCompat.Token - get() = songPlayer.mediaSession.sessionToken + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession - val songPlayer: SongPlayer - get() = this@MusicService.songPlayer + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { + val connectionResult = super.onConnect(session, controller) + return MediaSession.ConnectionResult.accept( + connectionResult.availableSessionCommands.buildUpon() + .add(CommandToggleLibrary) + .add(CommandToggleLike).build(), + connectionResult.availablePlayerCommands + ) + } - val cache: SimpleCache - get() = this@MusicService.songPlayer.cache + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle, + ): ListenableFuture { + when (customCommand.customAction) { + ACTION_TOGGLE_LIKE -> toggleLike() + ACTION_TOGGLE_LIBRARY -> toggleLibrary() + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: LibraryParams?, + ): ListenableFuture> = Futures.immediateFuture( + LibraryResult.ofItem( + MediaItem.Builder() + .setMediaId(ROOT) + .setMediaMetadata( + MediaMetadata.Builder() + .setIsPlayable(false) + .setIsBrowsable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + .build() + ) + .build(), + params + ) + ) + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams?, + ): ListenableFuture>> = scope.future(Dispatchers.IO) { + LibraryResult.ofItemList( + when (parentId) { + ROOT -> listOf( + browsableMediaItem(SONG, getString(R.string.songs), null, drawableUri(R.drawable.music_note), MEDIA_TYPE_PLAYLIST), + browsableMediaItem(ARTIST, getString(R.string.artists), null, drawableUri(R.drawable.artist), MEDIA_TYPE_FOLDER_ARTISTS), + browsableMediaItem(ALBUM, getString(R.string.albums), null, drawableUri(R.drawable.album), MEDIA_TYPE_FOLDER_ALBUMS), + browsableMediaItem(PLAYLIST, getString(R.string.playlists), null, drawableUri(R.drawable.queue_music), MEDIA_TYPE_FOLDER_PLAYLISTS) + ) + + SONG -> database.songsByCreateDateAsc().first().map { it.toMediaItem(parentId) } + ARTIST -> database.artistsByCreateDateAsc().first().map { artist -> + browsableMediaItem("$ARTIST/${artist.id}", artist.artist.name, resources.getQuantityString(R.plurals.n_song, artist.songCount, artist.songCount), artist.artist.thumbnailUrl?.toUri(), MEDIA_TYPE_ARTIST) + } + + ALBUM -> database.albumsByCreateDateAsc().first().map { album -> + browsableMediaItem("$ALBUM/${album.id}", album.album.title, album.artists.joinToString(), album.album.thumbnailUrl?.toUri(), MEDIA_TYPE_ALBUM) + } + + PLAYLIST -> { + val likedSongCount = database.likedSongsCount().first() + val downloadedSongCount = downloadUtil.downloads.value.size + listOf( + browsableMediaItem("$PLAYLIST/$LIKED_PLAYLIST_ID", getString(R.string.liked_songs), resources.getQuantityString(R.plurals.n_song, likedSongCount, likedSongCount), drawableUri(R.drawable.favorite), MEDIA_TYPE_PLAYLIST), + browsableMediaItem("$PLAYLIST/$DOWNLOADED_PLAYLIST_ID", getString(R.string.downloaded_songs), resources.getQuantityString(R.plurals.n_song, downloadedSongCount, downloadedSongCount), drawableUri(R.drawable.download), MEDIA_TYPE_PLAYLIST) + ) + database.playlistsByCreateDateAsc().first().map { playlist -> + browsableMediaItem("$PLAYLIST/${playlist.id}", playlist.playlist.name, resources.getQuantityString(R.plurals.n_song, playlist.songCount, playlist.songCount), playlist.thumbnails.firstOrNull()?.toUri(), MEDIA_TYPE_PLAYLIST) + } + } + + else -> when { + parentId.startsWith("$ARTIST/") -> + database.artistSongsByCreateDateAsc(parentId.removePrefix("$ARTIST/")).first().map { + it.toMediaItem(parentId) + } + + parentId.startsWith("$ALBUM/") -> + database.albumSongs(parentId.removePrefix("$ALBUM/")).first().map { + it.toMediaItem(parentId) + } + + parentId.startsWith("$PLAYLIST/") -> + when (val playlistId = parentId.removePrefix("$PLAYLIST/")) { + LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, true) + DOWNLOADED_PLAYLIST_ID -> { + val downloads = downloadUtil.downloads.value + database.songs( + downloads.filter { (_, download) -> + download.state == Download.STATE_COMPLETED + }.keys.toList() + ).map { songs -> + songs.map { it to downloads[it.id] } + .sortedBy { it.second?.updateTimeMs ?: 0L } + .map { it.first } + } + } - override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot = BrowserRoot(ROOT, null) + else -> database.playlistSongs(playlistId).map { list -> + list.map { it.song } + } + }.first().map { + it.toMediaItem(parentId) + } - override fun onLoadChildren(parentId: String, result: Result>) = runBlocking { - when (parentId) { - ROOT -> result.sendResult(mutableListOf( - mediaBrowserItem(SONG, getString(R.string.title_songs), null, drawableUri(R.drawable.ic_music_note)), - mediaBrowserItem(ARTIST, getString(R.string.title_artists), null, drawableUri(R.drawable.ic_artist)), - mediaBrowserItem(ALBUM, getString(R.string.title_albums), null, drawableUri(R.drawable.ic_album)), - mediaBrowserItem(PLAYLIST, getString(R.string.title_playlists), null, drawableUri(R.drawable.ic_queue_music)) - )) + else -> emptyList() + } + }, + params + ) + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String, + ): ListenableFuture> = scope.future(Dispatchers.IO) { + database.song(mediaId).first()?.toMediaItem()?.let { + LibraryResult.ofItem(it, null) + } ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN) + } + + override fun onSetMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long, + ): ListenableFuture = scope.future { + // Play from Android Auto + val defaultResult = MediaSession.MediaItemsWithStartPosition(emptyList(), startIndex, startPositionMs) + val path = mediaItems.firstOrNull()?.mediaId?.split("/") + ?: return@future defaultResult + when (path.firstOrNull()) { SONG -> { - result.detach() - result.sendResult(songRepository.getAllSongs(SongSortInfoPreference).flow.first().map { - MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) - }.toMutableList()) + val songId = path.getOrNull(1) ?: return@future defaultResult + val allSongs = database.songsByCreateDateAsc().first() + MediaSession.MediaItemsWithStartPosition( + allSongs.map { it.toMediaItem() }, + allSongs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) } + ARTIST -> { - result.detach() - result.sendResult(songRepository.getAllArtists(ArtistSortInfoPreference).flow.first().map { artist -> - mediaBrowserItem("$ARTIST/${artist.id}", artist.artist.name, resources.getQuantityString(R.plurals.song_count, artist.songCount, artist.songCount), artist.artist.thumbnailUrl?.toUri()) - }.toMutableList()) + val songId = path.getOrNull(2) ?: return@future defaultResult + val artistId = path.getOrNull(1) ?: return@future defaultResult + val songs = database.artistSongsByCreateDateAsc(artistId).first() + MediaSession.MediaItemsWithStartPosition( + songs.map { it.toMediaItem() }, + songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) } + ALBUM -> { - result.detach() - result.sendResult(songRepository.getAllAlbums(AlbumSortInfoPreference).flow.first().map { album -> - mediaBrowserItem("$ALBUM/${album.id}", album.album.title, album.artists.joinToString(), album.album.thumbnailUrl?.toUri()) - }.toMutableList()) + val songId = path.getOrNull(2) ?: return@future defaultResult + val albumId = path.getOrNull(1) ?: return@future defaultResult + val albumWithSongs = database.albumWithSongs(albumId).first() ?: return@future defaultResult + MediaSession.MediaItemsWithStartPosition( + albumWithSongs.songs.map { it.toMediaItem() }, + albumWithSongs.songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) } + PLAYLIST -> { - result.detach() - val likedSongCount = songRepository.getLikedSongCount().first() - val downloadedSongCount = songRepository.getDownloadedSongCount().first() - result.sendResult((listOf( - mediaBrowserItem("$PLAYLIST/$LIKED_PLAYLIST_ID", getString(R.string.liked_songs), resources.getQuantityString(R.plurals.song_count, likedSongCount, likedSongCount), drawableUri(R.drawable.ic_favorite)), - mediaBrowserItem("$PLAYLIST/$DOWNLOADED_PLAYLIST_ID", getString(R.string.downloaded_songs), resources.getQuantityString(R.plurals.song_count, downloadedSongCount, downloadedSongCount), drawableUri(R.drawable.ic_save_alt)) - ) + songRepository.getAllPlaylists(PlaylistSortInfoPreference).flow.first().filter { it.playlist.isLocalPlaylist }.map { playlist -> - mediaBrowserItem("$PLAYLIST/${playlist.id}", playlist.playlist.name, resources.getQuantityString(R.plurals.song_count, playlist.songCount, playlist.songCount), playlist.playlist.thumbnailUrl?.toUri() ?: playlist.thumbnails.firstOrNull()?.toUri()) - }).toMutableList()) - } - else -> when { - parentId.startsWith("$ARTIST/") -> { - result.detach() - result.sendResult(songRepository.getArtistSongs(parentId.removePrefix("$ARTIST/"), SongSortInfoPreference).flow.first().map { - MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) - }.toMutableList()) - } - parentId.startsWith("$ALBUM/") -> { - result.detach() - result.sendResult(songRepository.getAlbumSongs(parentId.removePrefix("$ALBUM/")).map { - MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) - }.toMutableList()) - } - parentId.startsWith("$PLAYLIST/") -> { - result.detach() - result.sendResult(when (val playlistId = parentId.removePrefix("$PLAYLIST/")) { - LIKED_PLAYLIST_ID -> songRepository.getLikedSongs(SongSortInfoPreference) - DOWNLOADED_PLAYLIST_ID -> songRepository.getDownloadedSongs(SongSortInfoPreference) - else -> songRepository.getPlaylistSongs(playlistId) - }.flow.first().map { - MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) - }.toMutableList()) - } - else -> { - result.sendResult(mutableListOf()) - } + val songId = path.getOrNull(2) ?: return@future defaultResult + val playlistId = path.getOrNull(1) ?: return@future defaultResult + val songs = when (playlistId) { + LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, descending = true) + DOWNLOADED_PLAYLIST_ID -> { + val downloads = downloadUtil.downloads.value + database.songs( + downloads.filter { (_, download) -> + download.state == Download.STATE_COMPLETED + }.keys.toList() + ).map { songs -> + songs.map { it to downloads[it.id] } + .sortedBy { it.second?.updateTimeMs ?: 0L } + .map { it.first } + } + } + + else -> database.playlistSongs(playlistId).map { list -> + list.map { it.song } + } + }.first() + MediaSession.MediaItemsWithStartPosition( + songs.map { it.toMediaItem() }, + songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) } + + else -> defaultResult } } @@ -167,16 +954,42 @@ class MusicService : LifecycleMediaBrowserService() { .appendPath(resources.getResourceEntryName(id)) .build() - private fun mediaBrowserItem(id: String, title: String, subtitle: String?, iconUri: Uri?, flags: Int = FLAG_BROWSABLE) = - MediaBrowserCompat.MediaItem( - MediaDescriptionCompat.Builder() - .setMediaId(id) - .setTitle(title) - .setSubtitle(subtitle) - .setIconUri(iconUri) - .build(), - flags - ) + private fun browsableMediaItem(id: String, title: String, subtitle: String?, iconUri: Uri?, mediaType: Int = MEDIA_TYPE_MUSIC) = + MediaItem.Builder() + .setMediaId(id) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setArtist(subtitle) + .setArtworkUri(iconUri) + .setIsPlayable(false) + .setIsBrowsable(true) + .setMediaType(mediaType) + .build() + ) + .build() + + private fun Song.toMediaItem(path: String) = + MediaItem.Builder() + .setMediaId("$path/$id") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(song.title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(song.thumbnailUrl?.toUri()) + .setIsPlayable(true) + .setIsBrowsable(false) + .setMediaType(MEDIA_TYPE_MUSIC) + .build() + ) + .build() + + inner class MusicBinder : Binder() { + val service: MusicService + get() = this@MusicService + } companion object { const val ROOT = "root" @@ -184,5 +997,11 @@ class MusicService : LifecycleMediaBrowserService() { const val ARTIST = "artist" const val ALBUM = "album" const val PLAYLIST = "playlist" + + const val CHANNEL_ID = "music_channel_01" + const val NOTIFICATION_ID = 888 + const val ERROR_CODE_NO_STREAM = 1000001 + const val CHUNK_LENGTH = 512 * 1024L + const val PERSISTENT_QUEUE_FILE = "persistent_queue.data" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt new file mode 100644 index 000000000..1fa3de085 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt @@ -0,0 +1,180 @@ +package com.zionhuang.music.playback + +import android.graphics.Bitmap +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM +import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM +import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.REPEAT_MODE_ONE +import androidx.media3.common.Player.STATE_ENDED +import androidx.media3.common.Timeline +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.extensions.currentMetadata +import com.zionhuang.music.extensions.getCurrentQueueIndex +import com.zionhuang.music.extensions.getQueueWindows +import com.zionhuang.music.extensions.metadata +import com.zionhuang.music.playback.MusicService.MusicBinder +import com.zionhuang.music.playback.queues.Queue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn + +@OptIn(ExperimentalCoroutinesApi::class) +class PlayerConnection( + binder: MusicBinder, + val database: MusicDatabase, + scope: CoroutineScope, +) : Player.Listener { + val service = binder.service + val player = service.player + + val playbackState = MutableStateFlow(player.playbackState) + val playWhenReady = MutableStateFlow(player.playWhenReady) + val isPlaying = combine(playbackState, playWhenReady) { playbackState, playWhenReady -> + playWhenReady && playbackState != STATE_ENDED + }.stateIn(scope, SharingStarted.Lazily, player.playWhenReady && player.playbackState != STATE_ENDED) + val mediaMetadata = MutableStateFlow(player.currentMetadata) + val currentSong = mediaMetadata.flatMapLatest { + database.song(it?.id) + } + val currentLyrics = mediaMetadata.flatMapLatest { mediaMetadata -> + database.lyrics(mediaMetadata?.id) + } + val currentFormat = mediaMetadata.flatMapLatest { mediaMetadata -> + database.format(mediaMetadata?.id) + } + + val queueTitle = MutableStateFlow(null) + val queueWindows = MutableStateFlow>(emptyList()) + val currentMediaItemIndex = MutableStateFlow(-1) + val currentWindowIndex = MutableStateFlow(-1) + + val shuffleModeEnabled = MutableStateFlow(false) + val repeatMode = MutableStateFlow(REPEAT_MODE_OFF) + + val canSkipPrevious = MutableStateFlow(true) + val canSkipNext = MutableStateFlow(true) + + var onBitmapChanged: (Bitmap?) -> Unit = {} + set(value) { + field = value + service.bitmapProvider.onBitmapChanged = value + } + + val error = MutableStateFlow(null) + + init { + player.addListener(this) + service.bitmapProvider.onBitmapChanged = onBitmapChanged + + playbackState.value = player.playbackState + playWhenReady.value = player.playWhenReady + mediaMetadata.value = player.currentMetadata + queueTitle.value = service.queueTitle + queueWindows.value = player.getQueueWindows() + currentWindowIndex.value = player.getCurrentQueueIndex() + currentMediaItemIndex.value = player.currentMediaItemIndex + shuffleModeEnabled.value = player.shuffleModeEnabled + repeatMode.value = player.repeatMode + } + + fun playQueue(queue: Queue) { + service.playQueue(queue) + } + + fun playNext(item: MediaItem) = playNext(listOf(item)) + fun playNext(items: List) { + service.playNext(items) + } + + fun addToQueue(item: MediaItem) = addToQueue(listOf(item)) + fun addToQueue(items: List) { + service.addToQueue(items) + } + + fun toggleRepeatMode() { + player.let { + it.repeatMode = when (it.repeatMode) { + REPEAT_MODE_OFF -> REPEAT_MODE_ALL + REPEAT_MODE_ALL -> REPEAT_MODE_ONE + REPEAT_MODE_ONE -> REPEAT_MODE_OFF + else -> throw IllegalStateException() + } + } + } + + fun toggleLike() { + service.toggleLike() + } + + fun toggleLibrary() { + service.toggleLibrary() + } + + override fun onPlaybackStateChanged(state: Int) { + playbackState.value = state + error.value = player.playerError + } + + override fun onPlayWhenReadyChanged(newPlayWhenReady: Boolean, reason: Int) { + playWhenReady.value = newPlayWhenReady + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + mediaMetadata.value = mediaItem?.metadata + currentMediaItemIndex.value = player.currentMediaItemIndex + currentWindowIndex.value = player.getCurrentQueueIndex() + updateCanSkipPreviousAndNext() + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + queueWindows.value = player.getQueueWindows() + queueTitle.value = service.queueTitle + currentMediaItemIndex.value = player.currentMediaItemIndex + currentWindowIndex.value = player.getCurrentQueueIndex() + updateCanSkipPreviousAndNext() + } + + override fun onShuffleModeEnabledChanged(enabled: Boolean) { + shuffleModeEnabled.value = enabled + queueWindows.value = player.getQueueWindows() + currentWindowIndex.value = player.getCurrentQueueIndex() + updateCanSkipPreviousAndNext() + } + + override fun onRepeatModeChanged(mode: Int) { + repeatMode.value = mode + updateCanSkipPreviousAndNext() + } + + override fun onPlayerErrorChanged(playbackError: PlaybackException?) { + error.value = playbackError + } + + private fun updateCanSkipPreviousAndNext() { + if (!player.currentTimeline.isEmpty) { + val window = player.currentTimeline.getWindow(player.currentMediaItemIndex, Timeline.Window()) + canSkipPrevious.value = player.isCommandAvailable(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) + || !window.isLive() + || player.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + canSkipNext.value = window.isLive() && window.isDynamic + || player.isCommandAvailable(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + } else { + canSkipPrevious.value = false + canSkipNext.value = false + } + } + + fun dispose() { + service.bitmapProvider.onBitmapChanged = {} + player.removeListener(this) + } +} diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt deleted file mode 100644 index 7d1b0ea39..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ /dev/null @@ -1,769 +0,0 @@ -package com.zionhuang.music.playback - -import android.app.PendingIntent -import android.app.PendingIntent.FLAG_IMMUTABLE -import android.app.PendingIntent.FLAG_UPDATE_CURRENT -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.media.audiofx.AudioEffect -import android.net.ConnectivityManager -import android.net.Uri -import android.os.Bundle -import android.os.ResultReceiver -import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat.* -import android.util.Pair -import androidx.core.app.NotificationCompat -import androidx.core.content.getSystemService -import androidx.core.net.toUri -import androidx.core.util.component1 -import androidx.core.util.component2 -import com.google.android.exoplayer2.* -import com.google.android.exoplayer2.C.WAKE_MODE_NETWORK -import com.google.android.exoplayer2.PlaybackException.* -import com.google.android.exoplayer2.Player.* -import com.google.android.exoplayer2.Player.State -import com.google.android.exoplayer2.analytics.AnalyticsListener -import com.google.android.exoplayer2.analytics.PlaybackStats -import com.google.android.exoplayer2.analytics.PlaybackStatsListener -import com.google.android.exoplayer2.audio.* -import com.google.android.exoplayer2.audio.DefaultAudioSink.* -import com.google.android.exoplayer2.audio.SilenceSkippingAudioProcessor.DEFAULT_SILENCE_THRESHOLD_LEVEL -import com.google.android.exoplayer2.database.StandaloneDatabaseProvider -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor.* -import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource -import com.google.android.exoplayer2.source.DefaultMediaSourceFactory -import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder -import com.google.android.exoplayer2.ui.PlayerNotificationManager -import com.google.android.exoplayer2.ui.PlayerNotificationManager.CustomActionReceiver -import com.google.android.exoplayer2.ui.PlayerNotificationManager.EXTRA_INSTANCE_ID -import com.google.android.exoplayer2.upstream.DefaultDataSource -import com.google.android.exoplayer2.upstream.ResolvingDataSource -import com.google.android.exoplayer2.upstream.cache.CacheDataSource -import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor -import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor -import com.google.android.exoplayer2.upstream.cache.SimpleCache -import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.models.* -import com.zionhuang.innertube.models.QueueAddEndpoint.Companion.INSERT_AFTER_CURRENT_VIDEO -import com.zionhuang.innertube.models.QueueAddEndpoint.Companion.INSERT_AT_END -import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.ACTION_SHOW_BOTTOM_SHEET -import com.zionhuang.music.constants.Constants.DOWNLOADED_PLAYLIST_ID -import com.zionhuang.music.constants.Constants.LIKED_PLAYLIST_ID -import com.zionhuang.music.constants.MediaConstants.EXTRA_MEDIA_METADATA_ITEMS -import com.zionhuang.music.constants.MediaConstants.STATE_DOWNLOADED -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_ADD_TO_LIBRARY -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_LIKE -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_REMOVE_FROM_LIBRARY -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIBRARY -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIKE -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_SHUFFLE -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_UNLIKE -import com.zionhuang.music.constants.MediaSessionConstants.COMMAND_ADD_TO_QUEUE -import com.zionhuang.music.constants.MediaSessionConstants.COMMAND_PLAY_NEXT -import com.zionhuang.music.constants.MediaSessionConstants.COMMAND_SEEK_TO_QUEUE_ITEM -import com.zionhuang.music.constants.MediaSessionConstants.EXTRA_QUEUE_INDEX -import com.zionhuang.music.db.entities.FormatEntity -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.* -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.sortInfo.SongSortInfoPreference -import com.zionhuang.music.playback.MusicService.Companion.ALBUM -import com.zionhuang.music.playback.MusicService.Companion.ARTIST -import com.zionhuang.music.playback.MusicService.Companion.PLAYLIST -import com.zionhuang.music.playback.MusicService.Companion.SONG -import com.zionhuang.music.playback.queues.EmptyQueue -import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.playback.queues.Queue -import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.activities.MainActivity -import com.zionhuang.music.ui.bindings.resizeThumbnailUrl -import com.zionhuang.music.ui.fragments.settings.StorageSettingsFragment.Companion.VALUE_TO_MB -import com.zionhuang.music.utils.InfoCache -import com.zionhuang.music.utils.lyrics.LyricsHelper -import com.zionhuang.music.utils.preference.enumPreference -import kotlinx.coroutines.* -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.flow.* -import okhttp3.OkHttpClient -import java.io.ObjectInputStream -import java.io.ObjectOutputStream -import java.net.ConnectException -import java.net.SocketTimeoutException -import java.net.UnknownHostException -import kotlin.math.min -import kotlin.math.pow -import kotlin.math.roundToInt - -/** - * A wrapper around [ExoPlayer] - */ -@OptIn(ExperimentalCoroutinesApi::class) -class SongPlayer( - private val context: Context, - private val scope: CoroutineScope, - notificationListener: PlayerNotificationManager.NotificationListener, -) : Listener, PlaybackStatsListener.Callback { - private val songRepository = SongRepository(context) - private val connectivityManager = context.getSystemService()!! - private val bitmapProvider = BitmapProvider(context) - - private var autoAddSong by context.preference(R.string.pref_auto_add_song, true) - private var audioQuality by enumPreference(context, R.string.pref_audio_quality, AudioQuality.AUTO) - - private var currentQueue: Queue = EmptyQueue() - - val playerVolume = MutableStateFlow(1f) - val currentMediaMetadata = MutableStateFlow(null) - private val currentSongFlow = currentMediaMetadata.flatMapLatest { mediaMetadata -> - songRepository.getSongById(mediaMetadata?.id).flow - } - private val currentFormat = currentMediaMetadata.flatMapLatest { mediaMetadata -> - songRepository.getSongFormat(mediaMetadata?.id).flow - } - var currentSong: Song? = null - - private val showLyrics = context.sharedPreferences.booleanFlow(context.getString(R.string.pref_show_lyrics), false) - - val mediaSession = MediaSessionCompat(context, context.getString(R.string.app_name)).apply { - isActive = true - } - - private val cacheEvictor = when (val cacheSize = (VALUE_TO_MB.getOrNull( - context.sharedPreferences.getInt(context.getString(R.string.pref_song_max_cache_size), 0)) - ?: 1024)) { - -1 -> NoOpCacheEvictor() - else -> LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L) - } - val cache = SimpleCache(context.cacheDir.resolve("exoplayer"), cacheEvictor, StandaloneDatabaseProvider(context)) - val player: ExoPlayer = ExoPlayer.Builder(context) - .setMediaSourceFactory(createMediaSourceFactory()) - .setRenderersFactory(createRenderersFactory()) - .setHandleAudioBecomingNoisy(true) - .setWakeMode(WAKE_MODE_NETWORK) - .setAudioAttributes(AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .build(), true) - .build() - .apply { - addListener(this@SongPlayer) - addAnalyticsListener(PlaybackStatsListener(false, this@SongPlayer)) - } - - private val mediaSessionConnector = MediaSessionConnector(mediaSession).apply { - setPlayer(player) - setPlaybackPreparer(object : MediaSessionConnector.PlaybackPreparer { - override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) = false - override fun getSupportedPrepareActions(): Long = ACTION_PREPARE or ACTION_PREPARE_FROM_MEDIA_ID or ACTION_PLAY_FROM_MEDIA_ID - override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { - scope.launch { - val path = mediaId.split("/") - when (path.firstOrNull()) { - SONG -> { - val songId = path.getOrNull(1) ?: return@launch - val allSongs = songRepository.getAllSongs(SongSortInfoPreference).flow.first() - playQueue(ListQueue( - title = context.getString(R.string.queue_all_songs), - items = allSongs.map { it.toMediaItem() }, - startIndex = allSongs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0 - ), playWhenReady) - } - ARTIST -> { - val songId = path.getOrNull(2) ?: return@launch - val artistId = path.getOrNull(1) ?: return@launch - val artist = songRepository.getArtistById(artistId) ?: return@launch - val songs = songRepository.getArtistSongs(artistId, SongSortInfoPreference).flow.first() - playQueue(ListQueue( - title = artist.name, - items = songs.map { it.toMediaItem() }, - startIndex = songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0 - ), playWhenReady) - } - ALBUM -> { - val songId = path.getOrNull(2) ?: return@launch - val albumId = path.getOrNull(1) ?: return@launch - val album = songRepository.getAlbum(albumId) ?: return@launch - val songs = songRepository.getAlbumSongs(albumId) - playQueue(ListQueue( - title = album.title, - items = songs.map { it.toMediaItem() }, - startIndex = songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0 - ), playWhenReady) - } - PLAYLIST -> { - val songId = path.getOrNull(2) ?: return@launch - val playlistId = path.getOrNull(1) ?: return@launch - val songs = when (playlistId) { - LIKED_PLAYLIST_ID -> songRepository.getLikedSongs(SongSortInfoPreference).flow.first() - DOWNLOADED_PLAYLIST_ID -> songRepository.getDownloadedSongs(SongSortInfoPreference).flow.first() - else -> songRepository.getPlaylistSongs(playlistId).getList() - } - playQueue(ListQueue( - title = when (playlistId) { - LIKED_PLAYLIST_ID -> context.getString(R.string.liked_songs) - DOWNLOADED_PLAYLIST_ID -> context.getString(R.string.downloaded_songs) - else -> songRepository.getPlaylistById(playlistId).playlist.name - }, - items = songs.map { it.toMediaItem() }, - startIndex = songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0 - ), playWhenReady) - } - } - } - } - - override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {} - override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {} - override fun onPrepare(playWhenReady: Boolean) { - player.playWhenReady = playWhenReady - player.prepare() - } - }) - registerCustomCommandReceiver { player, command, extras, _ -> - if (extras == null) return@registerCustomCommandReceiver false - when (command) { - COMMAND_MOVE_QUEUE_ITEM -> { - val from = extras.getInt(EXTRA_FROM_INDEX, C.INDEX_UNSET) - val to = extras.getInt(EXTRA_TO_INDEX, C.INDEX_UNSET) - if (from != C.INDEX_UNSET && to != C.INDEX_UNSET) { - player.moveMediaItem(from, to) - } - true - } - COMMAND_SEEK_TO_QUEUE_ITEM -> { - player.seekToDefaultPosition(extras.getInt(EXTRA_QUEUE_INDEX)) - true - } - COMMAND_PLAY_NEXT -> { - player.addMediaItems( - if (player.mediaItemCount == 0) 0 else player.currentMediaItemIndex + 1, - extras.getParcelableArray(EXTRA_MEDIA_METADATA_ITEMS)!!.filterIsInstance().map { it.toMediaItem() } - ) - player.prepare() - true - } - COMMAND_ADD_TO_QUEUE -> { - player.addMediaItems(extras.getParcelableArray(EXTRA_MEDIA_METADATA_ITEMS)!!.filterIsInstance().map { it.toMediaItem() }) - player.prepare() - true - } - else -> false - } - } - setCustomActionProviders( - object : MediaSessionConnector.CustomActionProvider { - override fun onCustomAction(player: Player, action: String, extras: Bundle?) = toggleLike() - override fun getCustomAction(player: Player) = if (currentMediaMetadata.value != null) { - CustomAction.Builder( - ACTION_TOGGLE_LIKE, - context.getString(if (currentSong?.song?.liked == true) R.string.action_remove_like else R.string.action_like), - if (currentSong?.song?.liked == true) R.drawable.ic_favorite else R.drawable.ic_favorite_border - ).build() - } else null - }, - object : MediaSessionConnector.CustomActionProvider { - override fun onCustomAction(player: Player, action: String, extras: Bundle?) = toggleLibrary() - override fun getCustomAction(player: Player) = if (currentMediaMetadata.value != null) { - CustomAction.Builder( - ACTION_TOGGLE_LIBRARY, - context.getString(if (currentSong != null) R.string.action_remove_from_library else R.string.action_add_to_library), - if (currentSong != null) R.drawable.ic_library_add_check else R.drawable.ic_library_add - ).build() - } else null - }, - object : MediaSessionConnector.CustomActionProvider { - override fun onCustomAction(player: Player, action: String, extras: Bundle?) { - player.shuffleModeEnabled = !player.shuffleModeEnabled - } - - override fun getCustomAction(player: Player) = - CustomAction.Builder( - ACTION_TOGGLE_SHUFFLE, - context.getString(R.string.btn_shuffle), - if (player.shuffleModeEnabled) R.drawable.ic_shuffle_on else R.drawable.ic_shuffle - ).build() - } - ) - setQueueNavigator { player, windowIndex -> player.getMediaItemAt(windowIndex).metadata!!.toMediaDescription(context) } - setErrorMessageProvider { e -> // e is ExoPlaybackException - val cause = e.cause?.cause as? PlaybackException // what we throw from resolving data source - Pair(cause?.errorCode ?: e.errorCode, cause?.message ?: e.message) - } - setQueueEditor(object : MediaSessionConnector.QueueEditor { - override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) = false - override fun onAddQueueItem(player: Player, description: MediaDescriptionCompat) = throw UnsupportedOperationException() - override fun onAddQueueItem(player: Player, description: MediaDescriptionCompat, index: Int) = throw UnsupportedOperationException() - override fun onRemoveQueueItem(player: Player, description: MediaDescriptionCompat) { - player.mediaItemIndexOf(description.mediaId)?.let { i -> - player.removeMediaItem(i) - } - } - }) - } - - private val playerNotificationManager = PlayerNotificationManager.Builder(context, NOTIFICATION_ID, CHANNEL_ID) - .setMediaDescriptionAdapter(object : PlayerNotificationManager.MediaDescriptionAdapter { - override fun getCurrentContentTitle(player: Player): CharSequence = - player.currentMetadata?.title.orEmpty() - - override fun getCurrentContentText(player: Player): CharSequence? = - player.currentMetadata?.artists?.joinToString { it.name } - - override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? = - player.currentMetadata?.thumbnailUrl?.let { url -> - bitmapProvider.load(resizeThumbnailUrl(url, (512 * context.resources.displayMetrics.density).roundToInt(), null)) { - callback.onBitmap(it) - } - } - - override fun createCurrentContentIntent(player: Player): PendingIntent? = - PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java).apply { - action = ACTION_SHOW_BOTTOM_SHEET - }, FLAG_IMMUTABLE) - }) - .setChannelNameResourceId(R.string.channel_name_playback) - .setNotificationListener(notificationListener) - .setCustomActionReceiver(object : CustomActionReceiver { - override fun createCustomActions(context: Context, instanceId: Int): Map = mapOf( - ACTION_ADD_TO_LIBRARY to NotificationCompat.Action.Builder( - R.drawable.ic_library_add, context.getString(R.string.action_add_to_library), createPendingIntent(context, ACTION_ADD_TO_LIBRARY, instanceId) - ).build(), - ACTION_REMOVE_FROM_LIBRARY to NotificationCompat.Action.Builder( - R.drawable.ic_library_add_check, context.getString(R.string.action_remove_from_library), createPendingIntent(context, ACTION_REMOVE_FROM_LIBRARY, instanceId) - ).build(), - ACTION_LIKE to NotificationCompat.Action.Builder( - R.drawable.ic_favorite_border, context.getString(R.string.action_like), createPendingIntent(context, ACTION_LIKE, instanceId) - ).build(), - ACTION_UNLIKE to NotificationCompat.Action.Builder( - R.drawable.ic_favorite, context.getString(R.string.action_remove_like), createPendingIntent(context, ACTION_UNLIKE, instanceId) - ).build() - ) - - override fun getCustomActions(player: Player): List { - val actions = mutableListOf() - if (player.currentMetadata != null && context.sharedPreferences.getBoolean(context.getString(R.string.pref_notification_more_action), true)) { - actions.add(if (currentSong == null) ACTION_ADD_TO_LIBRARY else ACTION_REMOVE_FROM_LIBRARY) - actions.add(if (currentSong?.song?.liked == true) ACTION_UNLIKE else ACTION_LIKE) - } - return actions - } - - override fun onCustomAction(player: Player, action: String, intent: Intent) { - when (action) { - ACTION_ADD_TO_LIBRARY, ACTION_REMOVE_FROM_LIBRARY -> toggleLibrary() - ACTION_LIKE, ACTION_UNLIKE -> toggleLike() - } - } - }) - .build() - .apply { - setPlayer(player) - setMediaSessionToken(mediaSession.sessionToken) - setSmallIcon(R.drawable.ic_notification) - setUseRewindAction(false) - setUseFastForwardAction(false) - } - - init { - scope.launch { - currentSongFlow.collect { song -> - val shouldInvalidate = currentSong == null || song == null || currentSong?.song?.liked != song.song.liked - currentSong = song - if (shouldInvalidate) { - mediaSessionConnector.invalidateMediaSessionPlaybackState() - playerNotificationManager.invalidate() - } - } - } - scope.launch { - combine(currentMediaMetadata.distinctUntilChangedBy { it?.id }, showLyrics) { mediaMetadata, showLyrics -> - Pair(mediaMetadata, showLyrics) - }.collectLatest { (mediaMetadata, showLyrics) -> - if (showLyrics && mediaMetadata != null && !songRepository.hasLyrics(mediaMetadata.id)) { - LyricsHelper.loadLyrics(context, mediaMetadata) - } - } - } - scope.launch { - context.sharedPreferences.booleanFlow(context.getString(R.string.pref_skip_silence), true).collectLatest { - player.skipSilenceEnabled = it - } - } - scope.launch { - combine(currentFormat, context.sharedPreferences.booleanFlow(context.getString(R.string.pref_audio_normalization), true)) { format, normalizeAudio -> - format to normalizeAudio - }.collectLatest { (format, normalizeAudio) -> - player.volume = if (normalizeAudio && format?.loudnessDb != null) { - min(10f.pow(-format.loudnessDb.toFloat() / 20), 1f) - } else { - 1f - } - } - } - scope.launch { - context.sharedPreferences.booleanFlow(context.getString(R.string.pref_notification_more_action), true).collectLatest { - playerNotificationManager.invalidate() - } - } - if (context.sharedPreferences.getBoolean(context.getString(R.string.pref_persistent_queue), true)) { - runCatching { - context.filesDir.resolve(PERSISTENT_QUEUE_FILE).inputStream().use { fis -> - ObjectInputStream(fis).use { oos -> - oos.readObject() as PersistQueue - } - } - }.onSuccess { queue -> - playQueue(ListQueue( - title = queue.title, - items = queue.items.map { it.toMediaItem() }, - startIndex = queue.mediaItemIndex, - position = queue.position - ), playWhenReady = false) - } - } - } - - private fun createOkHttpDataSourceFactory() = OkHttpDataSource.Factory(OkHttpClient.Builder() - .proxy(YouTube.proxy) - .build()) - - private fun createCacheDataSource() = CacheDataSource.Factory() - .setCache(cache) - .setUpstreamDataSourceFactory(DefaultDataSource.Factory(context, createOkHttpDataSourceFactory())) - - private fun createMediaSourceFactory() = DefaultMediaSourceFactory(ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> - runBlocking { - val mediaId = dataSpec.key ?: error("No media id") - - if (cache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH)) { - return@runBlocking dataSpec - } - - (InfoCache.getInfo(mediaId) as? String)?.let { url -> - return@runBlocking dataSpec.withUri(url.toUri()) - } - - // Check whether format exists so that users from older version can view format details - // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently - val playedFormat = songRepository.getSongFormat(mediaId).getValueAsync() - if (playedFormat != null && songRepository.getSongById(mediaId).getValueAsync()?.song?.downloadState == STATE_DOWNLOADED) { - return@runBlocking dataSpec.withUri(songRepository.getSongFile(mediaId).toUri()) - } - - withContext(IO) { - YouTube.player(mediaId) - }.map { playerResponse -> - if (playerResponse.playabilityStatus.status != "OK") { - throw PlaybackException(playerResponse.playabilityStatus.reason, null, ERROR_CODE_REMOTE_ERROR) - } - val format = if (playedFormat != null) { - playerResponse.streamingData?.adaptiveFormats?.find { - // Use itag to identify previous played format - it.itag == playedFormat.itag - } - } else { - playerResponse.streamingData?.adaptiveFormats - ?.filter { it.isAudio } - ?.maxByOrNull { - it.bitrate * when (audioQuality) { - AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 - AudioQuality.HIGH -> 1 - AudioQuality.LOW -> -1 - } - } - } ?: throw PlaybackException(context.getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) - songRepository.upsert(FormatEntity( - id = mediaId, - itag = format.itag, - mimeType = format.mimeType.split(";")[0], - codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), - bitrate = format.bitrate, - sampleRate = format.audioSampleRate, - contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb - )) - InfoCache.putInfo(mediaId, format.url, playerResponse.streamingData!!.expiresInSeconds * 1000L) - dataSpec.withUri(format.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) - }.getOrElse { throwable -> - if (throwable is ConnectException || throwable is UnknownHostException) { - throw PlaybackException(context.getString(R.string.error_no_internet), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_FAILED) - } - if (throwable is SocketTimeoutException) { - throw PlaybackException(context.getString(R.string.error_timeout), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT) - } - throw PlaybackException(context.getString(R.string.error_unknown), throwable, ERROR_CODE_REMOTE_ERROR) - } - } - }) - - private fun createRenderersFactory() = object : DefaultRenderersFactory(context) { - override fun buildAudioSink(context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, enableOffload: Boolean) = - DefaultAudioSink.Builder() - .setAudioCapabilities(AudioCapabilities.getCapabilities(context)) - .setEnableFloatOutput(enableFloatOutput) - .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) - .setOffloadMode(if (enableOffload) OFFLOAD_MODE_ENABLED_GAPLESS_REQUIRED else OFFLOAD_MODE_DISABLED) - .setAudioProcessorChain(DefaultAudioProcessorChain( - emptyArray(), - SilenceSkippingAudioProcessor(2_000_000, 20_000, DEFAULT_SILENCE_THRESHOLD_LEVEL), - SonicAudioProcessor() - )) - .build() - } - - fun playQueue(queue: Queue, playWhenReady: Boolean = true) { - currentQueue = queue - mediaSession.setQueueTitle(null) - player.clearMediaItems() - player.shuffleModeEnabled = false - - scope.launch(context.exceptionHandler) { - val initialStatus = withContext(IO) { queue.getInitialStatus() } - initialStatus.title?.let { queueTitle -> - mediaSession.setQueueTitle(queueTitle) - } - player.setMediaItems(initialStatus.items, if (initialStatus.index > 0) initialStatus.index else 0, initialStatus.position) - player.prepare() - if (playWhenReady) { - player.playWhenReady = true - } - } - } - - fun startRadioSeamlessly() { - val currentMediaMetadata = player.currentMetadata ?: return - if (player.currentMediaItemIndex > 0) player.removeMediaItems(0, player.currentMediaItemIndex) - if (player.currentMediaItemIndex < player.mediaItemCount - 1) player.removeMediaItems(player.currentMediaItemIndex + 1, player.mediaItemCount) - scope.launch(context.exceptionHandler) { - val radioQueue = YouTubeQueue(endpoint = WatchEndpoint(videoId = currentMediaMetadata.id)) - val initialStatus = radioQueue.getInitialStatus() - initialStatus.title?.let { queueTitle -> - mediaSession.setQueueTitle(queueTitle) - } - player.addMediaItems(initialStatus.items.drop(1)) - currentQueue = radioQueue - } - } - - fun handleQueueAddEndpoint(endpoint: QueueAddEndpoint, item: YTItem?) { - scope.launch(context.exceptionHandler) { - val items = when (item) { - is SongItem -> YouTube.getQueue(videoIds = listOf(item.id)).getOrThrow().map { it.toMediaItem() } - is AlbumItem -> withContext(IO) { - YouTube.browse(BrowseEndpoint(browseId = "VL" + item.playlistId)).getOrThrow().items.filterIsInstance().map { it.toMediaItem() } - // consider refetch by [YouTube.getQueue] if needed - } - is PlaylistItem -> withContext(IO) { - YouTube.getQueue(playlistId = endpoint.queueTarget.playlistId!!).getOrThrow().map { it.toMediaItem() } - } - is ArtistItem -> return@launch - null -> when { - endpoint.queueTarget.videoId != null -> withContext(IO) { - YouTube.getQueue(videoIds = listOf(endpoint.queueTarget.videoId!!)).getOrThrow().map { it.toMediaItem() } - } - endpoint.queueTarget.playlistId != null -> withContext(IO) { - YouTube.getQueue(playlistId = endpoint.queueTarget.playlistId).getOrThrow().map { it.toMediaItem() } - } - else -> error("Unknown queue target") - } - } - when (endpoint.queueInsertPosition) { - INSERT_AFTER_CURRENT_VIDEO -> player.addMediaItems((if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex) + 1, items) - INSERT_AT_END -> player.addMediaItems(items) - else -> {} - } - player.prepare() - } - } - - fun playNext(items: List) { - player.addMediaItems(if (player.mediaItemCount == 0) 0 else player.currentMediaItemIndex + 1, items) - player.prepare() - } - - fun addToQueue(items: List) { - player.addMediaItems(items) - player.prepare() - } - - fun toggleLibrary() { - scope.launch(context.exceptionHandler) { - val song = currentSong - val mediaMetadata = currentMediaMetadata.value ?: return@launch - if (song == null) { - songRepository.addSong(mediaMetadata) - } else { - songRepository.deleteSong(song) - } - } - } - - fun toggleLike() { - scope.launch(context.exceptionHandler) { - val song = currentSong - val mediaMetadata = currentMediaMetadata.value ?: return@launch - if (song == null) { - songRepository.addSong(mediaMetadata) - songRepository.getSongById(mediaMetadata.id).getValueAsync()?.let { - songRepository.toggleLiked(it) - } - } else { - songRepository.toggleLiked(song) - } - } - } - - private fun addToLibrary(mediaMetadata: MediaMetadata) { - scope.launch(context.exceptionHandler) { - songRepository.addSong(mediaMetadata) - } - } - - private fun openAudioEffectSession() { - context.sendBroadcast( - Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) - putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) - putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) - } - ) - } - - private fun closeAudioEffectSession() { - context.sendBroadcast( - Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) - } - ) - } - - /** - * Auto load more - */ - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - if (reason == MEDIA_ITEM_TRANSITION_REASON_REPEAT || - player.playbackState == STATE_IDLE || - player.mediaItemCount - player.currentMediaItemIndex > 5 || - !currentQueue.hasNextPage() - ) return - scope.launch(context.exceptionHandler) { - player.addMediaItems(currentQueue.nextPage()) - } - } - - override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, @DiscontinuityReason reason: Int) { - if (reason == DISCONTINUITY_REASON_AUTO_TRANSITION && autoAddSong) { - oldPosition.mediaItem?.metadata?.let { - addToLibrary(it) - } - } - } - - override fun onPlaybackStateChanged(@State playbackState: Int) { - if (playbackState == STATE_ENDED && autoAddSong) { - player.currentMetadata?.let { - addToLibrary(it) - } - } - } - - override fun onEvents(player: Player, events: Events) { - if (events.containsAny(EVENT_PLAYBACK_STATE_CHANGED, EVENT_PLAY_WHEN_READY_CHANGED, EVENT_IS_PLAYING_CHANGED, EVENT_POSITION_DISCONTINUITY)) { - if (player.playbackState != STATE_ENDED && player.playWhenReady) { - openAudioEffectSession() - } else { - closeAudioEffectSession() - } - } - if (events.containsAny(EVENT_TIMELINE_CHANGED, EVENT_POSITION_DISCONTINUITY)) { - currentMediaMetadata.value = player.currentMetadata - } - } - - override fun onPlaybackStatsReady(eventTime: AnalyticsListener.EventTime, playbackStats: PlaybackStats) { - val mediaItem = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem - scope.launch { - songRepository.incrementSongTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) - } - } - - override fun onVolumeChanged(volume: Float) { - playerVolume.value = volume - } - - override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { - if (shuffleModeEnabled) { - // Always put current playing item at first - val shuffledIndices = IntArray(player.mediaItemCount) - for (i in 0 until player.mediaItemCount) { - shuffledIndices[i] = i - } - shuffledIndices.shuffle() - shuffledIndices[shuffledIndices.indexOf(player.currentMediaItemIndex)] = shuffledIndices[0] - shuffledIndices[0] = player.currentMediaItemIndex - player.setShuffleOrder(DefaultShuffleOrder(shuffledIndices, System.currentTimeMillis())) - } - } - - private fun saveQueueToDisk() { - if (player.playbackState == STATE_IDLE) { - context.filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() - return - } - val persistQueue = PersistQueue( - title = mediaSession.controller.queueTitle?.toString(), - items = player.mediaItems.mapNotNull { it.metadata }, - mediaItemIndex = player.currentMediaItemIndex, - position = player.currentPosition - ) - runCatching { - context.filesDir.resolve(PERSISTENT_QUEUE_FILE).outputStream().use { fos -> - ObjectOutputStream(fos).use { oos -> - oos.writeObject(persistQueue) - } - } - }.onFailure { - it.printStackTrace() - } - } - - fun onDestroy() { - if (context.sharedPreferences.getBoolean(context.getString(R.string.pref_persistent_queue), true)) { - saveQueueToDisk() - } - mediaSession.apply { - isActive = false - release() - } - mediaSessionConnector.setPlayer(null) - playerNotificationManager.setPlayer(null) - player.removeListener(this) - player.release() - cache.release() - } - - enum class AudioQuality { - AUTO, HIGH, LOW - } - - companion object { - const val CHANNEL_ID = "music_channel_01" - const val NOTIFICATION_ID = 888 - const val ERROR_CODE_NO_STREAM = 1000001 - const val CHUNK_LENGTH = 512 * 1024L - const val PERSISTENT_QUEUE_FILE = "persistent_queue.data" - - fun createPendingIntent(context: Context, action: String, instanceId: Int): PendingIntent = PendingIntent.getBroadcast( - context, - instanceId, - Intent(action).setPackage(context.packageName).putExtra(EXTRA_INSTANCE_ID, instanceId), - FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/EmptyQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/EmptyQueue.kt index 61f664b1b..828106528 100644 --- a/app/src/main/java/com/zionhuang/music/playback/queues/EmptyQueue.kt +++ b/app/src/main/java/com/zionhuang/music/playback/queues/EmptyQueue.kt @@ -1,8 +1,10 @@ package com.zionhuang.music.playback.queues -import com.google.android.exoplayer2.MediaItem +import androidx.media3.common.MediaItem +import com.zionhuang.music.models.MediaMetadata -class EmptyQueue : Queue { +object EmptyQueue : Queue { + override val preloadItem: MediaMetadata? = null override suspend fun getInitialStatus() = Queue.Status(null, emptyList(), -1) override fun hasNextPage() = false override suspend fun nextPage() = emptyList() diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/ListQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/ListQueue.kt index c91179fbe..7d50c8463 100644 --- a/app/src/main/java/com/zionhuang/music/playback/queues/ListQueue.kt +++ b/app/src/main/java/com/zionhuang/music/playback/queues/ListQueue.kt @@ -1,6 +1,7 @@ package com.zionhuang.music.playback.queues -import com.google.android.exoplayer2.MediaItem +import androidx.media3.common.MediaItem +import com.zionhuang.music.models.MediaMetadata class ListQueue( val title: String? = null, @@ -8,9 +9,8 @@ class ListQueue( val startIndex: Int = 0, val position: Long = 0L, ) : Queue { + override val preloadItem: MediaMetadata? = null override suspend fun getInitialStatus() = Queue.Status(title, items, startIndex, position) - override fun hasNextPage(): Boolean = false - override suspend fun nextPage() = throw UnsupportedOperationException() } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/Queue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/Queue.kt index 4ce78b419..8c6d52682 100644 --- a/app/src/main/java/com/zionhuang/music/playback/queues/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/playback/queues/Queue.kt @@ -1,8 +1,10 @@ package com.zionhuang.music.playback.queues -import com.google.android.exoplayer2.MediaItem +import androidx.media3.common.MediaItem +import com.zionhuang.music.models.MediaMetadata interface Queue { + val preloadItem: MediaMetadata? suspend fun getInitialStatus(): Status fun hasNextPage(): Boolean suspend fun nextPage(): List @@ -10,7 +12,7 @@ interface Queue { data class Status( val title: String?, val items: List, - val index: Int, + val mediaItemIndex: Int, val position: Long = 0L, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeAlbumRadio.kt b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeAlbumRadio.kt new file mode 100644 index 000000000..4c137cf06 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeAlbumRadio.kt @@ -0,0 +1,41 @@ +package com.zionhuang.music.playback.queues + +import androidx.media3.common.MediaItem +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.models.MediaMetadata +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext + +class YouTubeAlbumRadio( + private val playlistId: String, +) : Queue { + override val preloadItem: MediaMetadata? = null + private val endpoint = WatchEndpoint( + playlistId = playlistId, + params = "wAEB" + ) + private var continuation: String? = null + + override suspend fun getInitialStatus(): Queue.Status = withContext(IO) { + val albumSongs = YouTube.albumSongs(playlistId).getOrThrow() + val nextResult = YouTube.next(endpoint, continuation).getOrThrow() + continuation = nextResult.continuation + Queue.Status( + title = nextResult.title, + items = (albumSongs + nextResult.items.subList(albumSongs.size, nextResult.items.size)).map { it.toMediaItem() }, + mediaItemIndex = nextResult.currentIndex ?: 0 + ) + } + + override fun hasNextPage(): Boolean = continuation != null + + override suspend fun nextPage(): List { + val nextResult = withContext(IO) { + YouTube.next(endpoint, continuation).getOrThrow() + } + continuation = nextResult.continuation + return nextResult.items.map { it.toMediaItem() } + } +} diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt index 22cf1e9f8..16be620af 100644 --- a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt +++ b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt @@ -1,16 +1,16 @@ package com.zionhuang.music.playback.queues -import com.google.android.exoplayer2.MediaItem +import androidx.media3.common.MediaItem import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.WatchEndpoint -import com.zionhuang.innertube.models.YTItem import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.models.MediaMetadata import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.withContext class YouTubeQueue( - private val endpoint: WatchEndpoint, - item: YTItem? = null, + private var endpoint: WatchEndpoint, + override val preloadItem: MediaMetadata? = null, ) : Queue { private var continuation: String? = null @@ -18,11 +18,12 @@ class YouTubeQueue( val nextResult = withContext(IO) { YouTube.next(endpoint, continuation).getOrThrow() } + endpoint = nextResult.endpoint continuation = nextResult.continuation return Queue.Status( title = nextResult.title, items = nextResult.items.map { it.toMediaItem() }, - index = nextResult.currentIndex ?: 0 + mediaItemIndex = nextResult.currentIndex ?: 0 ) } @@ -32,7 +33,8 @@ class YouTubeQueue( val nextResult = withContext(IO) { YouTube.next(endpoint, continuation).getOrThrow() } + endpoint = nextResult.endpoint continuation = nextResult.continuation return nextResult.items.map { it.toMediaItem() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt b/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt deleted file mode 100644 index fa97243e2..000000000 --- a/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.zionhuang.music.provider - -import android.database.Cursor -import android.database.MatrixCursor -import android.os.CancellationSignal -import android.os.ParcelFileDescriptor -import android.provider.DocumentsContract.Document -import android.provider.DocumentsContract.Document.MIME_TYPE_DIR -import android.provider.DocumentsContract.Root -import android.provider.DocumentsProvider -import com.google.android.exoplayer2.util.FileTypes -import com.zionhuang.music.R -import com.zionhuang.music.models.sortInfo.SongSortInfoPreference -import com.zionhuang.music.repos.SongRepository -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import java.io.FileNotFoundException -import java.time.ZoneOffset - -class SongsProvider : DocumentsProvider() { - private lateinit var songRepository: SongRepository - - override fun onCreate(): Boolean { - songRepository = SongRepository(context!!) - return true - } - - override fun queryRoots(projection: Array?): Cursor = - MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION).apply { - newRow() - .add(Root.COLUMN_ROOT_ID, ROOT) - .add(Root.COLUMN_DOCUMENT_ID, ROOT_DOC) - .add(Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) - .add(Root.COLUMN_ICON, R.drawable.ic_launcher_foreground) - .add(Root.COLUMN_MIME_TYPES, "*/*") - .add(Root.COLUMN_AVAILABLE_BYTES, context!!.filesDir.freeSpace) - .add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_SEARCH or Root.FLAG_SUPPORTS_IS_CHILD) - } - - override fun queryDocument(documentId: String, projection: Array?): Cursor = runBlocking { - MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION).apply { - when (documentId) { - ROOT_DOC -> newRow() - .add(Document.COLUMN_DOCUMENT_ID, documentId) - .add(Document.COLUMN_DISPLAY_NAME, context!!.getString(R.string.app_name)) - .add(Document.COLUMN_MIME_TYPE, MIME_TYPE_DIR) - else -> { - val song = songRepository.getSongById(documentId).getValueAsync() ?: throw FileNotFoundException() - val format = songRepository.getSongFormat(documentId).getValueAsync() ?: throw FileNotFoundException() - newRow() - .add(Document.COLUMN_DOCUMENT_ID, documentId) - .add(Document.COLUMN_DISPLAY_NAME, song.song.title) - .add(Document.COLUMN_MIME_TYPE, format.mimeType) - .add(Document.COLUMN_SIZE, format.contentLength) - .add(Document.COLUMN_LAST_MODIFIED, song.song.modifyDate.atZone(ZoneOffset.UTC).toInstant().toEpochMilli()) - } - } - } - } - - override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor = runBlocking { - MatrixCursor(DEFAULT_DOCUMENT_PROJECTION).apply { - when (parentDocumentId) { - ROOT_DOC -> songRepository.getDownloadedSongs(SongSortInfoPreference).flow.first().forEach { song -> - val format = songRepository.getSongFormat(song.id).getValueAsync() - if (format != null) { - newRow() - .add(Document.COLUMN_DOCUMENT_ID, song.id) - .add(Document.COLUMN_DISPLAY_NAME, "${song.song.title}${mimeToExt(format.mimeType)}") - .add(Document.COLUMN_MIME_TYPE, format.mimeType) - .add(Document.COLUMN_SIZE, format.contentLength) - .add(Document.COLUMN_LAST_MODIFIED, song.song.modifyDate.atZone(ZoneOffset.UTC).toInstant().toEpochMilli()) - } - } - } - } - } - - override fun querySearchDocuments(rootId: String, query: String, projection: Array?): Cursor = runBlocking { - MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION).apply { - when (rootId) { - ROOT -> { - songRepository.searchDownloadedSongs(query).first().forEach { song -> - val format = songRepository.getSongFormat(song.id).getValueAsync() - if (format != null) { - newRow() - .add(Document.COLUMN_DOCUMENT_ID, song.id) - .add(Document.COLUMN_DISPLAY_NAME, "${song.song.title}${mimeToExt(format.mimeType)}") - .add(Document.COLUMN_MIME_TYPE, format.mimeType) - .add(Document.COLUMN_SIZE, format.contentLength) - .add(Document.COLUMN_LAST_MODIFIED, song.song.modifyDate.atZone(ZoneOffset.UTC).toInstant().toEpochMilli()) - } - } - } - } - } - } - - override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = runBlocking { - val file = songRepository.getSongFile(documentId) - ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode)) - } - - override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean = runBlocking { - val song = songRepository.getSongById(documentId).getValueAsync() - song != null && parentDocumentId == ROOT_DOC - } - - private fun mimeToExt(mimeType: String) = when (FileTypes.inferFileTypeFromMimeType(mimeType)) { - FileTypes.AC3 -> ".ac3" - FileTypes.AC4 -> ".ac4" - FileTypes.ADTS -> ".adts" - FileTypes.AMR -> ".amr" - FileTypes.AVI -> ".avi" - FileTypes.FLAC -> ".flac" - FileTypes.FLV -> ".flv" - FileTypes.JPEG -> ".jpg" - FileTypes.MATROSKA -> ".webm" - FileTypes.MIDI -> ".midi" - FileTypes.MP3 -> ".mp3" - FileTypes.MP4 -> ".m4a" - FileTypes.OGG -> ".ogg" - FileTypes.PS -> ".ps" - FileTypes.TS -> ".ts" - FileTypes.WAV -> ".wav" - FileTypes.WEBVTT -> ".webvtt" - else -> "" - } - - companion object { - const val ROOT = "root" - const val ROOT_DOC = "root_dir" - - private val DEFAULT_ROOT_PROJECTION = arrayOf( - Root.COLUMN_ROOT_ID, - Root.COLUMN_DOCUMENT_ID, - Root.COLUMN_TITLE, - Root.COLUMN_SUMMARY, - Root.COLUMN_ICON, - Root.COLUMN_MIME_TYPES, - Root.COLUMN_AVAILABLE_BYTES, - Root.COLUMN_FLAGS - ) - private val DEFAULT_DOCUMENT_PROJECTION = arrayOf( - Document.COLUMN_DOCUMENT_ID, - Document.COLUMN_DISPLAY_NAME, - Document.COLUMN_MIME_TYPE, - Document.COLUMN_SIZE, - Document.COLUMN_LAST_MODIFIED, - Document.COLUMN_FLAGS - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt b/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt deleted file mode 100644 index 747af8290..000000000 --- a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt +++ /dev/null @@ -1,732 +0,0 @@ -package com.zionhuang.music.repos - -import android.app.DownloadManager -import android.content.Context -import android.net.ConnectivityManager -import androidx.core.content.getSystemService -import androidx.core.net.toUri -import androidx.lifecycle.distinctUntilChanged -import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.YouTube.MAX_GET_QUEUE_SIZE -import com.zionhuang.innertube.models.* -import com.zionhuang.innertube.models.ArtistHeader -import com.zionhuang.innertube.utils.browseAll -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.STATE_DOWNLOADED -import com.zionhuang.music.constants.MediaConstants.STATE_DOWNLOADING -import com.zionhuang.music.constants.MediaConstants.STATE_NOT_DOWNLOADED -import com.zionhuang.music.constants.MediaConstants.STATE_PREPARING -import com.zionhuang.music.db.MusicDatabase -import com.zionhuang.music.db.entities.* -import com.zionhuang.music.db.entities.ArtistEntity.Companion.generateArtistId -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.generatePlaylistId -import com.zionhuang.music.extensions.* -import com.zionhuang.music.models.DataWrapper -import com.zionhuang.music.models.ListWrapper -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.sortInfo.* -import com.zionhuang.music.playback.SongPlayer -import com.zionhuang.music.repos.base.LocalRepository -import com.zionhuang.music.ui.bindings.resizeThumbnailUrl -import com.zionhuang.music.utils.md5 -import com.zionhuang.music.utils.preference.enumPreference -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext -import java.io.File -import java.time.LocalDateTime - -class SongRepository(private val context: Context) : LocalRepository { - private val database = MusicDatabase.getInstance(context) - private val songDao = database.songDao - private val artistDao = database.artistDao - private val albumDao = database.albumDao - private val playlistDao = database.playlistDao - private val downloadDao = database.downloadDao - private val searchHistoryDao = database.searchHistoryDao - private val formatDao = database.formatDao - private val lyricsDao = database.lyricsDao - - private val connectivityManager = context.getSystemService()!! - private var autoDownload by context.preference(R.string.pref_auto_download, false) - private var audioQuality by enumPreference(context, R.string.pref_audio_quality, SongPlayer.AudioQuality.AUTO) - - /** - * Browse - */ - override fun getAllSongs(sortInfo: ISortInfo): ListWrapper = ListWrapper( - getFlow = { - if (sortInfo.type == SongSortType.ARTIST) { - songDao.getAllSongsAsFlow(SortInfo(SongSortType.CREATE_DATE, true)).map { list -> - list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } - } else { - songDao.getAllSongsAsFlow(sortInfo) - } - } - ) - - override suspend fun getSongCount() = withContext(IO) { songDao.getSongCount() } - - override fun getAllArtists(sortInfo: ISortInfo) = ListWrapper( - getFlow = { - if (sortInfo.type == ArtistSortType.SONG_COUNT) { - artistDao.getAllArtistsAsFlow(SortInfo(ArtistSortType.CREATE_DATE, true)).map { list -> - list.sortedBy { it.songCount }.reversed(sortInfo.isDescending) - } - } else { - artistDao.getAllArtistsAsFlow(sortInfo) - } - } - ) - - override suspend fun getArtistCount() = withContext(IO) { artistDao.getArtistCount() } - - override suspend fun getArtistSongsPreview(artistId: String): Result> = withContext(IO) { - runCatching { - if (artistDao.hasArtist(artistId)) { - listOf(Header( - title = context.getString(R.string.header_from_your_library), - moreNavigationEndpoint = NavigationEndpoint( - browseLocalArtistSongsEndpoint = BrowseLocalArtistSongsEndpoint(artistId) - ) - )) + YouTube.getQueue(videoIds = songDao.getArtistSongsPreview(artistId)).getOrThrow() - } else { - emptyList() - } - } - } - - override fun getArtistSongs(artistId: String, sortInfo: ISortInfo): ListWrapper = ListWrapper( - getList = { withContext(IO) { songDao.getArtistSongsAsList(artistId, sortInfo) } }, - getFlow = { - songDao.getArtistSongsAsFlow(artistId, if (sortInfo.type == SongSortType.ARTIST) SortInfo(SongSortType.CREATE_DATE, sortInfo.isDescending) else sortInfo) - } - ) - - override suspend fun getArtistSongCount(artistId: String) = withContext(IO) { songDao.getArtistSongCount(artistId) } - - override fun getAllAlbums(sortInfo: ISortInfo) = ListWrapper( - getFlow = { - if (sortInfo.type == AlbumSortType.ARTIST) { - albumDao.getAllAlbumsAsFlow(SortInfo(AlbumSortType.CREATE_DATE, true)).map { list -> - list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } - } else { - albumDao.getAllAlbumsAsFlow(sortInfo) - } - } - ) - - override suspend fun getAlbumCount() = withContext(IO) { albumDao.getAlbumCount() } - override suspend fun getAlbumSongs(albumId: String) = withContext(IO) { - songDao.getAlbumSongs(albumId) - } - - override fun getAllPlaylists(sortInfo: ISortInfo) = ListWrapper( - getList = { withContext(IO) { playlistDao.getAllPlaylistsAsList() } }, - getFlow = { - if (sortInfo.type == PlaylistSortType.SONG_COUNT) { - playlistDao.getAllPlaylistsAsFlow(SortInfo(PlaylistSortType.CREATE_DATE, true)).map { list -> - list.sortedBy { it.songCount }.reversed(sortInfo.isDescending) - } - } else { - playlistDao.getAllPlaylistsAsFlow(sortInfo) - } - } - ) - - override fun getPlaylistSongs(playlistId: String): ListWrapper = ListWrapper( - getList = { withContext(IO) { songDao.getPlaylistSongsAsList(playlistId) } }, - getFlow = { songDao.getPlaylistSongsAsFlow(playlistId) } - ) - - override fun getLikedSongs(sortInfo: ISortInfo): ListWrapper = ListWrapper( - getFlow = { - if (sortInfo.type == SongSortType.ARTIST) { - songDao.getLikedSongs(SortInfo(SongSortType.CREATE_DATE, true)).map { list -> - list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } - } else { - songDao.getLikedSongs(sortInfo) - } - } - ) - - override fun getLikedSongCount(): Flow = songDao.getLikedSongCount() - - override fun getDownloadedSongs(sortInfo: ISortInfo): ListWrapper = ListWrapper( - getList = { - withContext(IO) { - if (sortInfo.type == SongSortType.ARTIST) { - songDao.getDownloadedSongsAsList(SortInfo(SongSortType.CREATE_DATE, true)) - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } else { - songDao.getDownloadedSongsAsList(sortInfo) - } - } - }, - getFlow = { - if (sortInfo.type == SongSortType.ARTIST) { - songDao.getDownloadedSongsAsFlow(SortInfo(SongSortType.CREATE_DATE, true)).map { list -> - list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } - } else { - songDao.getDownloadedSongsAsFlow(sortInfo) - } - } - ) - - override fun getDownloadedSongCount(): Flow = songDao.getDownloadedSongCount() - - /** - * Search - */ - override fun searchAll(query: String): Flow> = combine( - songDao.searchSongsPreview(query, 3).map { if (it.isNotEmpty()) listOf(TextHeader(context.getString(R.string.search_filter_songs))) + it else emptyList() }, - artistDao.searchArtistsPreview(query, 3).map { if (it.isNotEmpty()) listOf(TextHeader(context.getString(R.string.search_filter_artists))) + it else emptyList() }, - albumDao.searchAlbumsPreview(query, 3).map { if (it.isNotEmpty()) listOf(TextHeader(context.getString(R.string.search_filter_albums))) + it else emptyList() }, - playlistDao.searchPlaylistsPreview(query, 3).map { if (it.isNotEmpty()) listOf(TextHeader(context.getString(R.string.search_filter_playlists))) + it else emptyList() } - ) { songResult, artistResult, albumResult, playlistResult -> songResult + artistResult + albumResult + playlistResult } - - override fun searchSongs(query: String) = songDao.searchSongs(query) - override fun searchDownloadedSongs(query: String) = songDao.searchDownloadedSongs(query) - override fun searchArtists(query: String) = artistDao.searchArtists(query) - override fun searchAlbums(query: String) = albumDao.searchAlbums(query) - override fun searchPlaylists(query: String) = playlistDao.searchPlaylists(query) - - /** - * Song - */ - override suspend fun addSong(mediaMetadata: MediaMetadata): SongEntity = withContext(IO) { - songDao.getSong(mediaMetadata.id)?.let { - return@withContext it.song - } - val song = mediaMetadata.toSongEntity() - songDao.insert(song) - mediaMetadata.artists.forEachIndexed { index, artist -> - artistDao.insert(ArtistEntity( - id = artist.id, - name = artist.name - )) - artistDao.insert(SongArtistMap( - songId = mediaMetadata.id, - artistId = artist.id, - position = index - )) - } - if (autoDownload) downloadSong(song) - song - } - - private suspend fun addSongs(items: List) = withContext(IO) { - val songs = items.map { it.toSongEntity() } - val songArtistMaps = items.flatMap { song -> - song.artists.mapIndexed { index, run -> - val artistId = (run.navigationEndpoint?.browseEndpoint?.browseId ?: getArtistByName(run.text)?.id ?: generateArtistId()).also { - artistDao.insert(ArtistEntity( - id = it, - name = run.text - )) - } - SongArtistMap( - songId = song.id, - artistId = artistId, - position = index - ) - } - } - songDao.insert(songs) - artistDao.insert(songArtistMaps) - if (autoDownload) downloadSongs(songs) - return@withContext songs - } - - override suspend fun safeAddSongs(songs: List): List = withContext(IO) { - // call [YouTube.getQueue] to ensure we get full information - addSongs(songs.chunked(MAX_GET_QUEUE_SIZE).flatMap { chunk -> - YouTube.getQueue(chunk.map { it.id }).getOrThrow() - }) - } - - override suspend fun refetchSongs(songs: List) = withContext(IO) { - val map = songs.associateBy { it.id } - val songItems = songs.chunked(MAX_GET_QUEUE_SIZE).flatMap { chunk -> - YouTube.getQueue(chunk.map { it.id }).getOrThrow() - } - songDao.update(songItems.map { item -> - map[item.id]!!.song.copy( - id = item.id, - title = item.title, - duration = item.duration!!, - thumbnailUrl = item.thumbnails.last().url, - albumId = item.album?.navigationEndpoint?.browseId, - albumName = item.album?.text, - modifyDate = LocalDateTime.now() - ) - }) - val songArtistMaps = songItems.flatMap { song -> - song.artists.mapIndexed { index, run -> - val artistId = (run.navigationEndpoint?.browseEndpoint?.browseId ?: getArtistByName(run.text)?.id ?: generateArtistId()).also { - artistDao.insert(ArtistEntity( - id = it, - name = run.text - )) - } - SongArtistMap( - songId = song.id, - artistId = artistId, - position = index - ) - } - } - artistDao.deleteSongArtists(songs.map { it.id }) - artistDao.insert(songArtistMaps) - artistDao.delete(songs - .flatMap { it.artists } - .distinctBy { it.id } - .filter { artistDao.getArtistSongCount(it.id) == 0 } - ) - } - - override fun getSongById(songId: String?) = DataWrapper( - getValueAsync = { withContext(IO) { songDao.getSong(songId) } }, - getLiveData = { songDao.getSongAsLiveData(songId).distinctUntilChanged() }, - getFlow = { songDao.getSongAsFlow(songId) } - ) - - override fun getSongFile(songId: String): File { - val mediaDir = context.getExternalFilesDir(null)!! / "media" - if (!mediaDir.isDirectory) mediaDir.mkdirs() - return mediaDir / md5(songId) - } - - private fun getSongTempFile(songId: String): File { - val mediaDir = context.getExternalFilesDir(null)!! / "media" - if (!mediaDir.isDirectory) mediaDir.mkdirs() - return mediaDir / (md5(songId) + ".tmp") - } - - override fun hasSong(songId: String): DataWrapper = DataWrapper( - getValueAsync = { songDao.hasSong(songId) }, - getLiveData = { songDao.hasSongAsLiveData(songId).distinctUntilChanged() } - ) - - override suspend fun incrementSongTotalPlayTime(songId: String, playTime: Long) = withContext(IO) { - songDao.incrementSongTotalPlayTime(songId, playTime) - } - - override suspend fun updateSongTitle(song: Song, newTitle: String) = withContext(IO) { - songDao.update(song.song.copy( - title = newTitle, - modifyDate = LocalDateTime.now() - )) - } - - override suspend fun toggleLiked(songs: List) = withContext(IO) { - songDao.update(songs.map { - it.song.copy( - liked = !it.song.liked, - modifyDate = LocalDateTime.now() - ) - }) - } - - override suspend fun downloadSongs(songs: List) = withContext(IO) { - songs.filter { it.downloadState == STATE_NOT_DOWNLOADED }.let { songs -> - songDao.update(songs.map { it.copy(downloadState = STATE_PREPARING) }) - songs.forEach { song -> - val playedFormat = getSongFormat(song.id).getValueAsync() - val playerResponse = YouTube.player(videoId = song.id).getOrThrow() - if (playerResponse.playabilityStatus.status == "OK") { - val format = if (playedFormat != null) { - playerResponse.streamingData?.adaptiveFormats?.find { it.itag == playedFormat.itag } - } else { - playerResponse.streamingData?.adaptiveFormats - ?.filter { it.isAudio } - ?.maxByOrNull { - it.bitrate * when (audioQuality) { - SongPlayer.AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 - SongPlayer.AudioQuality.HIGH -> 1 - SongPlayer.AudioQuality.LOW -> -1 - } - } - } - if (format == null) { - songDao.update(song.copy(downloadState = STATE_NOT_DOWNLOADED)) - // TODO - } else { - upsert(FormatEntity( - id = song.id, - itag = format.itag, - mimeType = format.mimeType.split(";")[0], - codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), - bitrate = format.bitrate, - sampleRate = format.audioSampleRate, - contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb - )) - songDao.update(song.copy(downloadState = STATE_DOWNLOADING)) - val downloadManager = context.getSystemService()!! - val req = DownloadManager.Request(format.url.toUri()) - .setTitle(song.title) - .setDestinationUri(getSongTempFile(song.id).toUri()) - val did = downloadManager.enqueue(req) - addDownloadEntity(DownloadEntity(did, song.id)) - } - } else { - songDao.update(song.copy(downloadState = STATE_NOT_DOWNLOADED)) - // TODO - } - } - } - } - - override suspend fun onDownloadComplete(downloadId: Long, success: Boolean): Unit = withContext(IO) { - getDownloadEntity(downloadId)?.songId?.let { songId -> - songDao.getSong(songId)?.let { song -> - songDao.update(song.song.copy(downloadState = if (success) STATE_DOWNLOADED else STATE_NOT_DOWNLOADED)) - getSongTempFile(songId).renameTo(getSongFile(songId)) - } - removeDownloadEntity(downloadId) - } - } - - override suspend fun validateDownloads() { - getDownloadedSongs(SongSortInfoPreference).getList().forEach { song -> - if (!getSongFile(song.id).exists() && !getSongTempFile(song.id).exists()) { - songDao.update(song.song.copy(downloadState = STATE_NOT_DOWNLOADED)) - } - } - } - - override suspend fun removeDownloads(songs: List) = withContext(IO) { - songs.forEach { song -> - if (getSongFile(song.song.id).exists()) { - getSongFile(song.song.id).delete() - } - songDao.update(song.song.copy(downloadState = STATE_NOT_DOWNLOADED)) - } - } - - override suspend fun moveToTrash(songs: List) = withContext(IO) { - songDao.update(songs.map { it.song.copy(isTrash = true) }) - } - - override suspend fun restoreFromTrash(songs: List) = withContext(IO) { - songDao.update(songs.map { it.song.copy(isTrash = false) }) - } - - override suspend fun deleteSongs(songs: List) = withContext(IO) { - val deletableSongs = songs.filter { it.album == null } - val renewPlaylists = playlistDao.getPlaylistSongMaps(deletableSongs.map { it.id }).groupBy { it.playlistId }.mapValues { entry -> - entry.value.minOf { it.position } - 1 - } - songDao.delete(deletableSongs.map { it.song }) - deletableSongs.forEach { song -> - getSongFile(song.song.id).delete() - } - artistDao.delete(deletableSongs - .flatMap { it.artists } - .distinctBy { it.id } - .filter { artistDao.getArtistSongCount(it.id) == 0 }) - renewPlaylists.forEach { (playlistId, position) -> - playlistDao.renewSongPositions(playlistId, position) - } - } - - /** - * Artist - */ - override suspend fun getArtistById(artistId: String): ArtistEntity? = withContext(IO) { - artistDao.getArtistById(artistId) - } - - override suspend fun getArtistByName(name: String): ArtistEntity? = withContext(IO) { - artistDao.getArtistByName(name) - } - - override suspend fun refetchArtists(artists: List) = withContext(IO) { - artists.forEach { artist -> - if (artist.isYouTubeArtist) { - val browseResult = YouTube.browse(BrowseEndpoint(browseId = artist.id)).getOrThrow() - val header = browseResult.items.firstOrNull() - if (header is ArtistHeader) { - artistDao.update(artist.copy( - name = header.name, - thumbnailUrl = header.bannerThumbnails?.lastOrNull()?.url?.let { resizeThumbnailUrl(it, 400, 400) }, - bannerUrl = header.bannerThumbnails?.lastOrNull()?.url, - description = header.description, - lastUpdateTime = LocalDateTime.now() - )) - } - } - } - } - - override suspend fun updateArtist(artist: ArtistEntity) = withContext(IO) { - artistDao.update(artist) - } - - /** - * Album - */ - override suspend fun addAlbums(albums: List) = withContext(IO) { - albums.forEach { album -> - val ids = YouTube.browse(BrowseEndpoint(browseId = "VL" + album.playlistId)).getOrThrow().items.filterIsInstance().map { it.id } - YouTube.getQueue(videoIds = ids).getOrThrow().let { songs -> - albumDao.insert(AlbumEntity( - id = album.id, - title = album.title, - year = album.year, - thumbnailUrl = album.thumbnails.last().url, - songCount = songs.size, - duration = songs.sumOf { it.duration ?: 0 } - )) - addSongs(songs) - albumDao.upsert(songs.mapIndexed { index, songItem -> - SongAlbumMap( - songId = songItem.id, - albumId = album.id, - index = index - ) - }) - } - (YouTube.browse(BrowseEndpoint(browseId = album.id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader)?.artists?.forEachIndexed { index, run -> - val artistId = (run.navigationEndpoint?.browseEndpoint?.browseId ?: getArtistByName(run.text)?.id ?: generateArtistId()).also { - artistDao.insert(ArtistEntity( - id = it, - name = run.text - )) - } - albumDao.insert(AlbumArtistMap( - albumId = album.id, - artistId = artistId, - order = index - )) - } - } - } - - override suspend fun refetchAlbums(albums: List) = withContext(IO) { - albums.forEach { album -> - (YouTube.browse(BrowseEndpoint(browseId = album.id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader)?.let { header -> - albumDao.update(album.copy( - title = header.name, - thumbnailUrl = header.thumbnails.lastOrNull()?.url, - year = header.year, - lastUpdateTime = LocalDateTime.now() - )) - } - } - } - - override suspend fun getAlbum(albumId: String) = withContext(IO) { - albumDao.getAlbumById(albumId) - } - - override suspend fun deleteAlbums(albums: List) = withContext(IO) { - albums.forEach { album -> - val songs = songDao.getAlbumSongs(album.id).map { it.copy(album = null) } - albumDao.delete(album.album) - deleteSongs(songs) - artistDao.delete(album.artists.filter { artistDao.getArtistSongCount(it.id) == 0 }) - } - } - - /** - * Playlist - */ - override suspend fun insertPlaylist(playlist: PlaylistEntity): Unit = withContext(IO) { playlistDao.insert(playlist) } - override suspend fun addPlaylists(playlists: List) = withContext(IO) { - playlists.forEach { playlist -> - (YouTube.browse(BrowseEndpoint(browseId = "VL" + playlist.id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader)?.let { header -> - playlistDao.insert(header.toPlaylistEntity()) - } - } - } - - override suspend fun importPlaylists(playlists: List) = withContext(IO) { - playlists.forEach { playlist -> - val playlistId = generatePlaylistId() - playlistDao.insert(playlist.toPlaylistEntity().copy(id = playlistId)) - var index = 0 - val songs = YouTube.browseAll(BrowseEndpoint(browseId = "VL" + playlist.id)).getOrThrow().filterIsInstance() - safeAddSongs(songs) - playlistDao.insert(songs.map { - PlaylistSongMap( - playlistId = playlistId, - songId = it.id, - position = index++ - ) - }) - } - } - - private suspend fun addSongsToPlaylist(playlistId: String, songIds: List) { - var maxId = playlistDao.getPlaylistMaxId(playlistId) ?: -1 - playlistDao.insert(songIds.map { songId -> - PlaylistSongMap( - playlistId = playlistId, - songId = songId, - position = ++maxId - ) - }) - } - - override suspend fun addToPlaylist(playlist: PlaylistEntity, items: List) = withContext(IO) { - val songIds = items.flatMap { item -> - when (item) { - is Song -> listOf(item).map { it.id } - is Album -> getAlbumSongs(item.id).map { it.id } - is Artist -> getArtistSongs(item.id, SongSortInfoPreference).getList().map { it.id } - is Playlist -> if (item.playlist.isLocalPlaylist) { - getPlaylistSongs(item.id).getList().map { it.id } - } else { - safeAddSongs(YouTube.browseAll(BrowseEndpoint(browseId = "VL" + item.id)).getOrThrow().filterIsInstance()).map { it.id } - } - } - } - addSongsToPlaylist(playlist.id, songIds) - } - - override suspend fun addYouTubeItemsToPlaylist(playlist: PlaylistEntity, items: List) = withContext(IO) { - val songs = items.flatMap { item -> - when (item) { - is SongItem -> YouTube.getQueue(videoIds = listOf(item.id)).getOrThrow() - is AlbumItem -> YouTube.browse(BrowseEndpoint(browseId = "VL" + item.playlistId)).getOrThrow().items.filterIsInstance() // consider refetch by [YouTube.getQueue] if needed - is PlaylistItem -> YouTube.getQueue(playlistId = item.id).getOrThrow() - is ArtistItem -> emptyList() - } - } - addSongs(songs) - addSongsToPlaylist(playlist.id, songs.map { it.id }) - } - - override suspend fun addMediaItemToPlaylist(playlist: PlaylistEntity, item: MediaMetadata) = withContext(IO) { - val song = YouTube.getQueue(videoIds = listOf(item.id)).getOrThrow() - addSongs(song) - addSongsToPlaylist(playlist.id, song.map { it.id }) - } - - override suspend fun refetchPlaylists(playlists: List): Unit = withContext(IO) { - playlists.forEach { playlist -> - (YouTube.browse(BrowseEndpoint(browseId = "VL" + playlist.id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader)?.let { header -> - playlistDao.update(playlist.playlist.copy( - name = header.name, - author = header.artists?.firstOrNull()?.text, - authorId = header.artists?.firstOrNull()?.navigationEndpoint?.browseEndpoint?.browseId, - year = header.year, - thumbnailUrl = header.thumbnails.lastOrNull()?.url, - lastUpdateTime = LocalDateTime.now() - )) - } - } - } - - override suspend fun downloadPlaylists(playlists: List) = withContext(IO) { - downloadSongs(playlists - .filter { it.playlist.isLocalPlaylist } - .flatMap { getPlaylistSongs(it.id).getList() } - .distinctBy { it.id } - .map { it.song }) - } - - override suspend fun getPlaylistById(playlistId: String): Playlist = withContext(IO) { - playlistDao.getPlaylistById(playlistId) - } - - override suspend fun updatePlaylist(playlist: PlaylistEntity) = withContext(IO) { playlistDao.update(playlist) } - - override suspend fun movePlaylistItems(playlistId: String, from: Int, to: Int) = withContext(IO) { - val target = playlistDao.getPlaylistSongMap(playlistId, from) ?: return@withContext - if (to < from) { - playlistDao.incrementSongPositions(playlistId, to, from - 1) - } else if (from < to) { - playlistDao.decrementSongPositions(playlistId, from + 1, to) - } - playlistDao.update(target.copy(position = to)) - } - - override suspend fun removeSongFromPlaylist(playlistId: String, position: Int) = withContext(IO) { - playlistDao.deletePlaylistSong(playlistId, position) - playlistDao.decrementSongPositions(playlistId, position + 1) - } - - override suspend fun removeSongsFromPlaylist(playlistId: String, positions: List) = withContext(IO) { - playlistDao.deletePlaylistSong(playlistId, positions) - playlistDao.renewSongPositions(playlistId, positions.minOrNull()!! - 1) - } - - override suspend fun deletePlaylists(playlists: List) = withContext(IO) { - playlistDao.delete(playlists) - } - - /** - * Download - */ - override suspend fun addDownloadEntity(item: DownloadEntity) = withContext(IO) { downloadDao.insert(item) } - override suspend fun getDownloadEntity(downloadId: Long): DownloadEntity? = withContext(IO) { downloadDao.getDownloadEntity(downloadId) } - override suspend fun removeDownloadEntity(downloadId: Long) = withContext(IO) { downloadDao.delete(downloadId) } - - /** - * Search history - */ - override suspend fun getAllSearchHistory() = withContext(IO) { - searchHistoryDao.getAllHistory() - } - - override suspend fun getSearchHistory(query: String) = withContext(IO) { - searchHistoryDao.getHistory(query) - } - - override suspend fun insertSearchHistory(query: String) = withContext(IO) { - searchHistoryDao.insert(SearchHistory(query = query)) - } - - override suspend fun deleteSearchHistory(query: String) = withContext(IO) { - searchHistoryDao.delete(query) - } - - override suspend fun clearSearchHistory() { - searchHistoryDao.clearHistory() - } - - /** - * Format - */ - override fun getSongFormat(songId: String?): DataWrapper = DataWrapper( - getValueAsync = { withContext(IO) { formatDao.getSongFormat(songId) } }, - getFlow = { formatDao.getSongFormatAsFlow(songId) } - ) - - override suspend fun upsert(format: FormatEntity) = withContext(IO) { - formatDao.upsert(format) - } - - /** - * Lyrics - */ - override fun getLyrics(songId: String?): Flow = - lyricsDao.getLyricsAsFlow(songId) - - override suspend fun hasLyrics(songId: String): Boolean = withContext(IO) { - lyricsDao.hasLyrics(songId) - } - - override suspend fun upsert(lyrics: LyricsEntity) = withContext(IO) { - lyricsDao.upsert(lyrics) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt b/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt deleted file mode 100644 index d1a961e04..000000000 --- a/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.zionhuang.music.repos - -import android.content.Context -import androidx.paging.PagingSource -import androidx.paging.PagingState -import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.YouTube.EXPLORE_BROWSE_ID -import com.zionhuang.innertube.YouTube.HOME_BROWSE_ID -import com.zionhuang.innertube.models.* -import com.zionhuang.innertube.models.Icon.Companion.ICON_EXPLORE -import com.zionhuang.innertube.utils.plus -import com.zionhuang.music.R -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.toPage -import com.zionhuang.music.utils.InfoCache.checkCache -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.withContext - -class YouTubeRepository(val context: Context) { - private val songRepository = SongRepository(context) - - fun searchAll(query: String) = object : PagingSource, YTBaseItem>() { - override suspend fun load(params: LoadParams>) = withContext(IO) { - try { - YouTube.searchAllType(query).getOrThrow().toPage() - } catch (e: Exception) { - LoadResult.Error(e) - } - } - - override fun getRefreshKey(state: PagingState, YTBaseItem>): List? = null - } - - fun search(query: String, filter: YouTube.SearchFilter): PagingSource, YTBaseItem> = object : PagingSource, YTBaseItem>() { - override suspend fun load(params: LoadParams>) = withContext(IO) { - try { - if (params.key == null) { - YouTube.search(query, filter).getOrThrow() - } else { - YouTube.search(params.key!![0]).getOrThrow() - }.toPage() - } catch (e: Exception) { - LoadResult.Error(e) - } - } - - override fun getRefreshKey(state: PagingState, YTBaseItem>): List? = null - } - - fun browse(endpoint: BrowseEndpoint): PagingSource, YTBaseItem> = object : PagingSource, YTBaseItem>() { - override suspend fun load(params: LoadParams>) = withContext(IO) { - try { - if (params.key == null) { - val browseResult = YouTube.browse(endpoint).getOrThrow() - if (endpoint.browseId == HOME_BROWSE_ID) { - // inject explore link - browseResult.copy( - items = NavigationItem( - title = getApplication().getString(R.string.title_explore), - icon = ICON_EXPLORE, - navigationEndpoint = NavigationEndpoint( - browseEndpoint = BrowseEndpoint(browseId = EXPLORE_BROWSE_ID) - ) - ) + browseResult.items - ) - } else if (endpoint.isArtistEndpoint && endpoint.params == null) { - // inject library artist songs preview - browseResult.copy( - items = browseResult.items.toMutableList().apply { - addAll(if (browseResult.items.firstOrNull() is ArtistHeader) 1 else 0, songRepository.getArtistSongsPreview(endpoint.browseId).getOrThrow()) - } - ) - } else { - browseResult - } - } else { - YouTube.browse(params.key!!).getOrThrow() - }.toPage() - } catch (e: Exception) { - LoadResult.Error(e) - } - } - - override fun getRefreshKey(state: PagingState, YTBaseItem>): List? = null - } - - suspend fun getSuggestions(query: String): List = withContext(IO) { - checkCache("SU$query") { - YouTube.getSearchSuggestions(query).getOrThrow() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt b/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt deleted file mode 100644 index 5c90da4c6..000000000 --- a/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.zionhuang.music.repos.base - -import com.zionhuang.innertube.models.* -import com.zionhuang.music.db.entities.* -import com.zionhuang.music.models.DataWrapper -import com.zionhuang.music.models.ListWrapper -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.sortInfo.* -import kotlinx.coroutines.flow.Flow -import java.io.File - -interface LocalRepository { - /** - * Browse - */ - fun getAllSongs(sortInfo: ISortInfo): ListWrapper - suspend fun getSongCount(): Int - - fun getAllArtists(sortInfo: ISortInfo): ListWrapper - suspend fun getArtistCount(): Int - - suspend fun getArtistSongsPreview(artistId: String): Result> - fun getArtistSongs(artistId: String, sortInfo: ISortInfo): ListWrapper - suspend fun getArtistSongCount(artistId: String): Int - - fun getAllAlbums(sortInfo: ISortInfo): ListWrapper - suspend fun getAlbumCount(): Int - suspend fun getAlbumSongs(albumId: String): List - - fun getAllPlaylists(sortInfo: ISortInfo): ListWrapper - - fun getPlaylistSongs(playlistId: String): ListWrapper - - fun getLikedSongs(sortInfo: ISortInfo): ListWrapper - fun getLikedSongCount(): Flow - fun getDownloadedSongs(sortInfo: ISortInfo): ListWrapper - fun getDownloadedSongCount(): Flow - - /** - * Search - */ - fun searchAll(query: String): Flow> - fun searchSongs(query: String): Flow> - fun searchDownloadedSongs(query: String): Flow> - fun searchArtists(query: String): Flow> - fun searchAlbums(query: String): Flow> - fun searchPlaylists(query: String): Flow> - - /** - * Song - */ - suspend fun addSong(mediaMetadata: MediaMetadata): SongEntity - suspend fun safeAddSong(song: SongItem) = safeAddSongs(listOf(song)) - suspend fun safeAddSongs(songs: List): List - suspend fun refetchSongs(songs: List) - fun getSongById(songId: String?): DataWrapper - fun getSongFile(songId: String): File - fun hasSong(songId: String): DataWrapper - suspend fun incrementSongTotalPlayTime(songId: String, playTime: Long) - suspend fun updateSongTitle(song: Song, newTitle: String) - suspend fun toggleLiked(song: Song) = toggleLiked(listOf(song)) - suspend fun toggleLiked(songs: List) - suspend fun downloadSong(song: SongEntity) = downloadSongs(listOf(song)) - suspend fun downloadSongs(songs: List) - suspend fun onDownloadComplete(downloadId: Long, success: Boolean) - suspend fun validateDownloads() - suspend fun removeDownloads(songs: List) - suspend fun moveToTrash(songs: List) - suspend fun restoreFromTrash(songs: List) - suspend fun deleteSong(song: Song) = deleteSongs(listOf(song)) - suspend fun deleteSongs(songs: List) - - /** - * Artist - */ - suspend fun getArtistById(artistId: String): ArtistEntity? - suspend fun getArtistByName(name: String): ArtistEntity? - suspend fun refetchArtist(artist: ArtistEntity) = refetchArtists(listOf(artist)) - suspend fun refetchArtists(artists: List) - suspend fun updateArtist(artist: ArtistEntity) - - /** - * Album - */ - suspend fun addAlbum(album: AlbumItem) = addAlbums(listOf(album)) - suspend fun addAlbums(albums: List) - suspend fun refetchAlbum(album: AlbumEntity) = refetchAlbums(listOf(album)) - suspend fun refetchAlbums(albums: List) - suspend fun getAlbum(albumId: String): AlbumEntity? - suspend fun deleteAlbums(albums: List) - - /** - * Playlist - */ - suspend fun insertPlaylist(playlist: PlaylistEntity) - suspend fun addPlaylist(playlist: PlaylistItem) = addPlaylists(listOf(playlist)) - suspend fun addPlaylists(playlists: List) - suspend fun importPlaylist(playlist: PlaylistItem) = importPlaylists(listOf(playlist)) - suspend fun importPlaylists(playlists: List) - suspend fun addToPlaylist(playlist: PlaylistEntity, item: LocalItem) = addToPlaylist(playlist, listOf(item)) - suspend fun addToPlaylist(playlist: PlaylistEntity, items: List) - suspend fun addYouTubeItemToPlaylist(playlist: PlaylistEntity, item: YTItem) = addYouTubeItemsToPlaylist(playlist, listOf(item)) - suspend fun addYouTubeItemsToPlaylist(playlist: PlaylistEntity, items: List) - suspend fun addMediaItemToPlaylist(playlist: PlaylistEntity, item: MediaMetadata) - suspend fun refetchPlaylists(playlists: List) - suspend fun downloadPlaylists(playlists: List) - suspend fun getPlaylistById(playlistId: String): Playlist - suspend fun updatePlaylist(playlist: PlaylistEntity) - suspend fun movePlaylistItems(playlistId: String, from: Int, to: Int) - suspend fun removeSongFromPlaylist(playlistId: String, position: Int) - suspend fun removeSongsFromPlaylist(playlistId: String, positions: List) - suspend fun deletePlaylists(playlists: List) - - /** - * Download - */ - suspend fun addDownloadEntity(item: DownloadEntity) - suspend fun getDownloadEntity(downloadId: Long): DownloadEntity? - suspend fun removeDownloadEntity(downloadId: Long) - - /** - * Search history - */ - suspend fun getAllSearchHistory(): List - suspend fun getSearchHistory(query: String): List - suspend fun insertSearchHistory(query: String) - suspend fun deleteSearchHistory(query: String) - suspend fun clearSearchHistory() - - /** - * Format - */ - fun getSongFormat(songId: String?): DataWrapper - suspend fun upsert(format: FormatEntity) - - /** - * Lyrics - */ - fun getLyrics(songId: String?): Flow - suspend fun hasLyrics(songId: String): Boolean - suspend fun upsert(lyrics: LyricsEntity) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/activities/ErrorActivity.kt b/app/src/main/java/com/zionhuang/music/ui/activities/ErrorActivity.kt deleted file mode 100644 index 6a2853db4..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/activities/ErrorActivity.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.zionhuang.music.ui.activities - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.widget.Toast -import android.widget.Toast.LENGTH_SHORT -import androidx.core.content.getSystemService -import androidx.core.net.toUri -import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.ERROR_INFO -import com.zionhuang.music.constants.Constants.GITHUB_ISSUE_URL -import com.zionhuang.music.databinding.ActivityErrorBinding -import com.zionhuang.music.models.ErrorInfo -import com.zionhuang.music.ui.activities.base.ThemedBindingActivity - -class ErrorActivity : ThemedBindingActivity() { - override fun getViewBinding() = ActivityErrorBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setSupportActionBar(binding.toolbar) - supportActionBar?.setHomeButtonEnabled(true) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - val errorInfo = intent.getParcelableExtra(ERROR_INFO)!! - binding.stacktrace.text = errorInfo.stackTrace - - binding.btnCopy.setOnClickListener { - val clipboardManager = getSystemService()!! - val clip = ClipData.newPlainText("stacktrace", errorInfo.stackTrace) - clipboardManager.setPrimaryClip(clip) - Toast.makeText(this, R.string.copied, LENGTH_SHORT).show() - } - binding.btnReport.setOnClickListener { - startActivity(Intent(Intent.ACTION_VIEW, GITHUB_ISSUE_URL.toUri())) - } - } - - companion object { - fun openActivity(context: Context, errorInfo: ErrorInfo) { - context.startActivity(getErrorActivityIntent(context, errorInfo)) - } - - private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo) = Intent(context, ErrorActivity::class.java).apply { - putExtra(ERROR_INFO, errorInfo) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/activities/MainActivity.kt b/app/src/main/java/com/zionhuang/music/ui/activities/MainActivity.kt deleted file mode 100644 index 39bc0bd51..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/activities/MainActivity.kt +++ /dev/null @@ -1,349 +0,0 @@ -package com.zionhuang.music.ui.activities - -import android.animation.ValueAnimator -import android.annotation.SuppressLint -import android.content.Intent -import android.content.Intent.EXTRA_TEXT -import android.content.res.Configuration -import android.content.res.Configuration.ORIENTATION_LANDSCAPE -import android.content.res.Configuration.ORIENTATION_PORTRAIT -import android.os.Bundle -import android.view.ActionMode -import android.view.View -import android.view.ViewGroup -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.view.* -import androidx.fragment.app.Fragment -import androidx.interpolator.view.animation.FastOutSlowInInterpolator -import androidx.lifecycle.lifecycleScope -import androidx.navigation.NavController -import androidx.navigation.NavDestination -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.NavigationUI.onNavDestinationSelected -import androidx.navigation.ui.setupWithNavController -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior.* -import com.google.android.material.floatingactionbutton.FloatingActionButton -import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.transition.MaterialFadeThrough -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.innertube.models.BrowseEndpoint -import com.zionhuang.innertube.models.BrowseEndpoint.Companion.artistBrowseEndpoint -import com.zionhuang.innertube.models.BrowseEndpoint.Companion.playlistBrowseEndpoint -import com.zionhuang.innertube.models.WatchEndpoint -import com.zionhuang.innertube.utils.YouTubeLinkHandler -import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.ACTION_SHOW_BOTTOM_SHEET -import com.zionhuang.music.constants.Constants.BOTTOM_SHEET_STATE -import com.zionhuang.music.constants.Constants.QUEUE_SHEET_STATE -import com.zionhuang.music.databinding.ActivityMainBinding -import com.zionhuang.music.extensions.* -import com.zionhuang.music.playback.MediaSessionConnection -import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.activities.base.ThemedBindingActivity -import com.zionhuang.music.ui.fragments.BottomControlsFragment -import com.zionhuang.music.ui.fragments.MiniPlayerFragment -import com.zionhuang.music.ui.fragments.QueueSheetFragment -import com.zionhuang.music.ui.fragments.base.AbsRecyclerViewFragment -import com.zionhuang.music.utils.AdaptiveUtils -import com.zionhuang.music.utils.NavigationEndpointHandler -import com.zionhuang.music.utils.NavigationTabHelper -import dev.chrisbanes.insetter.applyInsetter -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - - -class MainActivity : ThemedBindingActivity(), NavController.OnDestinationChangedListener { - override fun getViewBinding() = ActivityMainBinding.inflate(layoutInflater) - - private lateinit var navHostFragment: NavHostFragment - val currentFragment: Fragment? - get() = navHostFragment.childFragmentManager.fragments.firstOrNull() - - lateinit var bottomSheetBehavior: NeoBottomSheetBehavior<*> - lateinit var queueSheetBehavior: NeoBottomSheetBehavior<*> - - val fab: FloatingActionButton get() = binding.fab - - private var actionMode: ActionMode? = null - - @SuppressLint("PrivateResource", "ClickableViewAccessibility") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - WindowCompat.setDecorFitsSystemWindows(window, false) - - val defaultTabIndex = sharedPreferences.getString(getString(R.string.pref_default_open_tab), "0")!!.toInt() - navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment - val navController = navHostFragment.navController - val graph = navController.navInflater.inflate(R.navigation.main_navigation_graph) - graph.setStartDestination(listOf(R.id.homeFragment, R.id.songsFragment, R.id.artistsFragment, R.id.albumsFragment, R.id.playlistsFragment)[defaultTabIndex]) - navController.setGraph(graph, null) - navController.addOnDestinationChangedListener(this) - - val enabledItems = NavigationTabHelper.getConfig(this) - listOf(binding.bottomNav, binding.navigationRail).forEach { - it.menu.forEachIndexed { index, menuItem -> - if (!enabledItems[index]) { - menuItem.isVisible = false - } - } - } - - binding.bottomNav.setupWithNavController(navController) - binding.navigationRail.setupWithNavController(navController) - binding.bottomNav.setOnItemSelectedListener { item -> - if (item.isChecked) { - // scroll to top - (currentFragment as? AbsRecyclerViewFragment<*, *>)?.getRecyclerView()?.smoothScrollToPosition(0) - } else { - onNavDestinationSelected(item, navController) - item.isChecked = true - } - true - } - binding.navigationRail.setOnItemSelectedListener { item -> - if (item.isChecked) { - // scroll to top - (currentFragment as? AbsRecyclerViewFragment<*, *>)?.getRecyclerView()?.smoothScrollToPosition(0) - } else { - onNavDestinationSelected(item, navController) - item.isChecked = true - } - true - } - - binding.container.applyInsetter { - type(statusBars = true, navigationBars = true) { - padding(right = true) - } - } - binding.fab.setOnApplyWindowInsetsListener { v, insets -> - v.updateLayoutParams { - bottomMargin = (16 * getDensity()).toInt() + insets.systemBarInsetsCompat.bottom - } - insets - } - replaceFragment(R.id.mini_player_fragment, MiniPlayerFragment()) - replaceFragment(R.id.bottom_controls_fragment, BottomControlsFragment()) - replaceFragment(R.id.queue_fragment, QueueSheetFragment()) - binding.miniPlayerFragment.background = binding.bottomNav.background - bottomSheetBehavior = from(binding.bottomControlsSheet).apply { - maxWidth = ViewGroup.LayoutParams.MATCH_PARENT - isHideable = true - state = savedInstanceState?.getInt(BOTTOM_SHEET_STATE) ?: STATE_HIDDEN - addBottomSheetCallback(object : BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, @State newState: Int) { - onBottomSheetStateChanged(newState) - } - - override fun onSlide(bottomSheet: View, slideOffset: Float) { - onBottomSheetSlide(slideOffset) - } - }) - } - queueSheetBehavior = from(binding.queueSheet).apply { - maxWidth = ViewGroup.LayoutParams.MATCH_PARENT - state = savedInstanceState?.getInt(QUEUE_SHEET_STATE) ?: STATE_COLLAPSED - addBottomSheetCallback(object : BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - onQueueSheetStateChanged(newState) - } - - override fun onSlide(bottomSheet: View, slideOffset: Float) {} - }) - } - - lifecycleScope.launch { - SongRepository(this@MainActivity).validateDownloads() - } - preferenceLiveData(R.string.pref_show_lyrics, false).observe(this) { showLyrics -> - keepScreenOn(showLyrics && bottomSheetBehavior.state == STATE_EXPANDED) - } - lifecycleScope.launch { - AdaptiveUtils.orientation.collectLatest { orientation -> - binding.queueSheet.updateLayoutParams { - width = if (orientation == ORIENTATION_LANDSCAPE) (resources.displayMetrics.widthPixels * 0.5).toInt() else resources.displayMetrics.widthPixels - } - binding.container.updateLayoutParams { - bottomMargin = if (orientation == ORIENTATION_PORTRAIT) resources.getDimensionPixelSize(R.dimen.m3_bottom_nav_min_height) else 0 - } - binding.container.updatePadding(bottom = if (bottomSheetBehavior.state == STATE_HIDDEN) 0 else dip(R.dimen.bottom_controls_sheet_peek_height)) - binding.bottomNav.isVisible = orientation == ORIENTATION_PORTRAIT - binding.navigationRail.isVisible = orientation != ORIENTATION_PORTRAIT - bottomSheetBehavior.setPeekHeight( - (if (orientation == ORIENTATION_PORTRAIT) dip(R.dimen.m3_bottom_nav_min_height) else 0) + dip(R.dimen.bottom_controls_sheet_peek_height), - true - ) - onBottomSheetSlide(if (bottomSheetBehavior.state == STATE_EXPANDED) 1f else 0f) - } - } - AdaptiveUtils.updateOrientation(this) - handleIntent(intent) - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - AdaptiveUtils.updateOrientation(this) - } - - private fun onBottomSheetStateChanged(@State newState: Int) { - if (newState == STATE_COLLAPSED && binding.container.paddingBottom != dip(R.dimen.bottom_controls_sheet_peek_height)) { - ValueAnimator.ofInt(0, dip(R.dimen.bottom_controls_sheet_peek_height)).apply { - duration = resources.getInteger(R.integer.motion_duration_medium).toLong() - interpolator = FastOutSlowInInterpolator() - addUpdateListener { - binding.container.updatePadding(bottom = it.animatedValue as Int) - } - }.start() - } else if (newState == STATE_HIDDEN && binding.container.paddingBottom != 0) { - ValueAnimator.ofInt(dip(R.dimen.bottom_controls_sheet_peek_height), 0).apply { - duration = resources.getInteger(R.integer.motion_duration_medium).toLong() - interpolator = FastOutSlowInInterpolator() - addUpdateListener { - binding.container.updatePadding(bottom = it.animatedValue as Int) - } - }.start() - } - if (newState == STATE_HIDDEN) { - MediaSessionConnection.mediaController?.transportControls?.stop() - } - if (newState == STATE_COLLAPSED || newState == STATE_HIDDEN) { - keepScreenOn(false) - } else if (newState == STATE_EXPANDED && sharedPreferences.getBoolean(getString(R.string.pref_show_lyrics), false)) { - keepScreenOn(true) - } - } - - private fun onBottomSheetSlide(slideOffset: Float) { - val progress = slideOffset.coerceIn(0f, 1f) - binding.bottomNav.translationY = binding.bottomNav.height * progress - binding.bottomNav.isVisible = progress != 1f && AdaptiveUtils.orientation.value == ORIENTATION_PORTRAIT - binding.miniPlayerFragment.alpha = (1 - progress * 4).coerceIn(0f, 1f) // mini player disappears after sliding 25% - binding.bottomControlsFragment.alpha = ((progress - 0.25f) * 4).coerceIn(0f, 1f) - } - - private fun onQueueSheetStateChanged(@State newState: Int) { - bottomSheetBehavior.isDraggable = !(newState == STATE_EXPANDED || newState == STATE_DRAGGING) - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - intent?.let { handleIntent(it) } - } - - private fun handleIntent(intent: Intent) { - if (intent.action == ACTION_SHOW_BOTTOM_SHEET) { - bottomSheetBehavior.state = STATE_EXPANDED - onBottomSheetSlide(1f) - return - } - val url = (intent.data ?: intent.getStringExtra(EXTRA_TEXT))?.toString() ?: return - YouTubeLinkHandler.getVideoId(url)?.let { id -> - lifecycleScope.launch { - while (!MediaSessionConnection.isConnected.value) delay(300) - MediaSessionConnection.binder?.songPlayer?.playQueue(YouTubeQueue(WatchEndpoint(videoId = id))) - } - return - } - YouTubeLinkHandler.getBrowseId(url)?.let { id -> - currentFragment?.let { - NavigationEndpointHandler(it).handle(BrowseEndpoint(browseId = id)) - } - return - } - YouTubeLinkHandler.getPlaylistId(url)?.let { id -> - currentFragment?.let { - NavigationEndpointHandler(it).handle(playlistBrowseEndpoint("VL$id")) - } - return - } - YouTubeLinkHandler.getChannelId(url)?.let { id -> - currentFragment?.let { - NavigationEndpointHandler(it).handle(artistBrowseEndpoint(id)) - } - return - } - Snackbar.make(binding.mainContent, getString(R.string.snackbar_url_error), LENGTH_LONG).show() - } - - override fun onDestinationChanged(controller: NavController, destination: NavDestination, arguments: Bundle?) { - val topLevelDestinations = setOf( - R.id.homeFragment, - R.id.songsFragment, - R.id.artistsFragment, - R.id.albumsFragment, - R.id.playlistsFragment - ) - actionMode?.finish() - if (destination.id == R.id.playlistsFragment) { - binding.fab.show() - } else if (binding.fab.isVisible) { - binding.fab.hide() - } - if (destination.id == R.id.youtubeSuggestionFragment || destination.id == R.id.localSearchFragment) { - currentFragment?.exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()).addTarget(R.id.fragment_content) - currentFragment?.reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()).addTarget(R.id.fragment_content) - } - if (destination.id in topLevelDestinations) { - currentFragment?.reenterTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()).addTarget(R.id.fragment_content) - } - } - - fun showBottomSheet() { - val expandOnPlay by preference(R.string.pref_expand_on_play, false) - if (expandOnPlay) { - bottomSheetBehavior.state = STATE_EXPANDED - } else { - if (bottomSheetBehavior.state != STATE_EXPANDED) { - bottomSheetBehavior.state = STATE_COLLAPSED - } - } - queueSheetBehavior.state = STATE_COLLAPSED - } - - fun collapseBottomSheet() { - queueSheetBehavior.state = STATE_COLLAPSED - bottomSheetBehavior.state = STATE_COLLAPSED - } - - override fun onBackPressed() { - if (queueSheetBehavior.state != STATE_COLLAPSED) { - queueSheetBehavior.state = STATE_COLLAPSED - return - } - if (bottomSheetBehavior.state == STATE_EXPANDED) { - bottomSheetBehavior.state = STATE_COLLAPSED - return - } - super.onBackPressed() - } - - override fun onActionModeStarted(mode: ActionMode?) { - super.onActionModeStarted(mode) - actionMode = mode - } - - override fun onActionModeFinished(mode: ActionMode?) { - super.onActionModeFinished(mode) - actionMode = null - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putInt(BOTTOM_SHEET_STATE, bottomSheetBehavior.state) - outState.putInt(QUEUE_SHEET_STATE, queueSheetBehavior.state) - } - - override fun onStart() { - super.onStart() - MediaSessionConnection.connect(this) - } - - override fun onStop() { - MediaSessionConnection.disconnect(this) - super.onStop() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/activities/SettingsActivity.kt b/app/src/main/java/com/zionhuang/music/ui/activities/SettingsActivity.kt deleted file mode 100644 index ea0d89a3f..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/activities/SettingsActivity.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.zionhuang.music.ui.activities - -import android.os.Bundle -import androidx.navigation.findNavController -import androidx.navigation.fragment.NavHostFragment -import com.zionhuang.music.R -import com.zionhuang.music.databinding.ActivitySettingsBinding -import com.zionhuang.music.ui.activities.base.ThemedBindingActivity - -class SettingsActivity : ThemedBindingActivity() { - override fun getViewBinding() = ActivitySettingsBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setSupportActionBar(binding.toolbar) - val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment - val navController = navHostFragment.navController - navController.addOnDestinationChangedListener { _, destination, _ -> - binding.toolbar.title = destination.label - } - supportActionBar?.setHomeButtonEnabled(true) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - } - - override fun onSupportNavigateUp(): Boolean = - findNavController(R.id.nav_host_fragment).navigateUp() || super.onSupportNavigateUp() -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/activities/base/BindingActivity.kt b/app/src/main/java/com/zionhuang/music/ui/activities/base/BindingActivity.kt deleted file mode 100644 index 7eba5b1cc..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/activities/base/BindingActivity.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zionhuang.music.ui.activities.base - -import android.os.Bundle -import androidx.annotation.CallSuper -import androidx.appcompat.app.AppCompatActivity -import androidx.viewbinding.ViewBinding - -abstract class BindingActivity : AppCompatActivity() { - lateinit var binding: T - - abstract fun getViewBinding(): T - - @CallSuper - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = getViewBinding() - setContentView(binding.root) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/activities/base/ThemedBindingActivity.kt b/app/src/main/java/com/zionhuang/music/ui/activities/base/ThemedBindingActivity.kt deleted file mode 100644 index 508d0e187..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/activities/base/ThemedBindingActivity.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.zionhuang.music.ui.activities.base - -import android.content.SharedPreferences -import android.os.Bundle -import androidx.appcompat.app.AppCompatDelegate -import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -import androidx.viewbinding.ViewBinding -import com.google.android.material.color.DynamicColors -import com.zionhuang.music.R -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.utils.livedata.ThemeUtil -import com.zionhuang.music.utils.livedata.ThemeUtil.DEFAULT_THEME - -abstract class ThemedBindingActivity : BindingActivity(), SharedPreferences.OnSharedPreferenceChangeListener { - override fun onCreate(savedInstanceState: Bundle?) { - // Fix preference type mismatch in 0.3.0 - try { - sharedPreferences.getString(getString(R.string.pref_dark_theme), "MODE_FOLLOW_SYSTEM")!!.toInt() - } catch (e: Exception) { - sharedPreferences.edit() - .putString(getString(R.string.pref_dark_theme), MODE_NIGHT_FOLLOW_SYSTEM.toString()) - .commit() - } - - AppCompatDelegate.setDefaultNightMode(sharedPreferences.getString(getString(R.string.pref_dark_theme), MODE_NIGHT_FOLLOW_SYSTEM.toString())!!.toInt()) - if (DynamicColors.isDynamicColorAvailable() && sharedPreferences.getBoolean(getString(R.string.pref_follow_system_accent), true)) { - DynamicColors.applyToActivityIfAvailable(this) - } else { - setTheme(ThemeUtil.getColorThemeStyleRes(sharedPreferences.getString(getString(R.string.pref_theme_color), DEFAULT_THEME)!!)) - } - sharedPreferences.registerOnSharedPreferenceChangeListener(this) - super.onCreate(savedInstanceState) - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - if (key in listOf( - getString(R.string.pref_dark_theme), - getString(R.string.pref_follow_system_accent), - getString(R.string.pref_theme_color)) - ) { - recreate() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/DraggableLocalItemAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/DraggableLocalItemAdapter.kt deleted file mode 100644 index 8cd55acb9..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/DraggableLocalItemAdapter.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.annotation.SuppressLint -import android.view.MotionEvent -import android.view.ViewGroup -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.SelectionTracker.SELECTION_CHANGED_MARKER -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.db.entities.* -import com.zionhuang.music.extensions.inflateWithBinding -import com.zionhuang.music.ui.listeners.* -import com.zionhuang.music.ui.viewholders.* - -class DraggableLocalItemAdapter : RecyclerView.Adapter() { - var currentList: List = emptyList() - - var songMenuListener: ISongMenuListener? = null - var artistMenuListener: IArtistMenuListener? = null - var albumMenuListener: IAlbumMenuListener? = null - var playlistMenuListener: IPlaylistMenuListener? = null - var likedPlaylistMenuListener: LikedPlaylistMenuListener? = null - var downloadedPlaylistMenuListener: DownloadedPlaylistMenuListener? = null - - var tracker: SelectionTracker? = null - var allowMoreAction: Boolean = true // for choosing playlist - var onShuffle: () -> Unit = {} - - var itemTouchHelper: ItemTouchHelper? = null - var isDraggable: Boolean = false // for reorder playlist - - override fun onBindViewHolder(holder: LocalItemViewHolder, position: Int) { - val item = getItem(position) - when (holder) { - is SongViewHolder -> holder.bind(item as Song, tracker?.isSelected(getItem(position).id) ?: false) - is ArtistViewHolder -> holder.bind(item as Artist, tracker?.isSelected(getItem(position).id) ?: false) - is AlbumViewHolder -> holder.bind(item as Album, tracker?.isSelected(getItem(position).id) ?: false) - is PlaylistViewHolder -> holder.bind(item as Playlist, tracker?.isSelected(getItem(position).id) ?: false) - is CustomPlaylistViewHolder -> when (item) { - is LikedPlaylist -> holder.bind(item, likedPlaylistMenuListener) - is DownloadedPlaylist -> holder.bind(item, downloadedPlaylistMenuListener) - else -> {} - } - is SongHeaderViewHolder -> holder.bind(item as SongHeader) - is ArtistHeaderViewHolder -> holder.bind(item as ArtistHeader) - is AlbumHeaderViewHolder -> holder.bind(item as AlbumHeader) - is PlaylistHeaderViewHolder -> holder.bind(item as PlaylistHeader) - is PlaylistSongHeaderViewHolder -> holder.bind(item as PlaylistSongHeader) - is TextHeaderViewHolder -> holder.bind(item as TextHeader) - } - } - - override fun onBindViewHolder(holder: LocalItemViewHolder, position: Int, payloads: MutableList) { - val payload = payloads.firstOrNull() - when { - payload is SongHeader && holder is SongHeaderViewHolder -> holder.bind(payload, true) - payload is ArtistHeader && holder is ArtistHeaderViewHolder -> holder.bind(payload, true) - payload is AlbumHeader && holder is AlbumHeaderViewHolder -> holder.bind(payload, true) - payload is PlaylistHeader && holder is PlaylistHeaderViewHolder -> holder.bind(payload, true) - payload is PlaylistSongHeader && holder is PlaylistSongHeaderViewHolder -> holder.bind(payload) - payload is TextHeader && holder is TextHeaderViewHolder -> holder.bind(payload) - payload == SELECTION_CHANGED_MARKER -> holder.onSelectionChanged(tracker?.isSelected(getItem(position).id) ?: false) - else -> onBindViewHolder(holder, position) - } - } - - @SuppressLint("ClickableViewAccessibility") - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LocalItemViewHolder = when (viewType) { - TYPE_SONG -> SongViewHolder(parent.inflateWithBinding(R.layout.item_song), songMenuListener, isDraggable).apply { - binding.dragHandle.setOnTouchListener { _, event -> - if (tracker?.hasSelection() == false && event.actionMasked == MotionEvent.ACTION_DOWN) itemTouchHelper?.startDrag(this) - true - } - } - TYPE_ARTIST -> ArtistViewHolder(parent.inflateWithBinding(R.layout.item_artist), artistMenuListener) - TYPE_ALBUM -> AlbumViewHolder(parent.inflateWithBinding(R.layout.item_album), albumMenuListener) - TYPE_PLAYLIST -> PlaylistViewHolder(parent.inflateWithBinding(R.layout.item_playlist), playlistMenuListener, allowMoreAction) - TYPE_LIKED_PLAYLIST, TYPE_DOWNLOADED_PLAYLIST -> CustomPlaylistViewHolder(parent.inflateWithBinding(R.layout.item_custom_playlist)) - TYPE_SONG_HEADER -> SongHeaderViewHolder(parent.inflateWithBinding(R.layout.item_header), onShuffle) - TYPE_ARTIST_HEADER -> ArtistHeaderViewHolder(parent.inflateWithBinding(R.layout.item_header)) - TYPE_ALBUM_HEADER -> AlbumHeaderViewHolder(parent.inflateWithBinding(R.layout.item_header)) - TYPE_PLAYLIST_HEADER -> PlaylistHeaderViewHolder(parent.inflateWithBinding(R.layout.item_header)) - TYPE_PLAYLIST_SONG_HEADER -> PlaylistSongHeaderViewHolder(parent.inflateWithBinding(R.layout.item_playlist_header), onShuffle) - TYPE_TEXT_HEADER -> TextHeaderViewHolder(parent.inflateWithBinding(R.layout.item_text_header)) - else -> error("Unknown view type") - } - - override fun getItemViewType(position: Int): Int = when (getItem(position)) { - is Song -> TYPE_SONG - is Artist -> TYPE_ARTIST - is Album -> TYPE_ALBUM - is Playlist -> TYPE_PLAYLIST - is LikedPlaylist -> TYPE_LIKED_PLAYLIST - is DownloadedPlaylist -> TYPE_DOWNLOADED_PLAYLIST - is SongHeader -> TYPE_SONG_HEADER - is ArtistHeader -> TYPE_ARTIST_HEADER - is AlbumHeader -> TYPE_ALBUM_HEADER - is PlaylistHeader -> TYPE_PLAYLIST_HEADER - is PlaylistSongHeader -> TYPE_PLAYLIST_SONG_HEADER - is TextHeader -> TYPE_TEXT_HEADER - } - - @SuppressLint("NotifyDataSetChanged") - fun submitList(newList: List, animation: Boolean = true) { - val oldList = currentList - currentList = newList - if (animation) { - DiffUtil.calculateDiff(object : DiffUtil.Callback() { - override fun getOldListSize(): Int = oldList.size - override fun getNewListSize(): Int = newList.size - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = itemComparator.areItemsTheSame(oldList[oldItemPosition], newList[newItemPosition]) - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = itemComparator.areContentsTheSame(oldList[oldItemPosition], newList[newItemPosition]) - override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int) = itemComparator.getChangePayload(oldList[oldItemPosition], newList[newItemPosition]) - }).dispatchUpdatesTo(this) - } else { - notifyDataSetChanged() - } - } - - private fun getItem(position: Int): LocalBaseItem = currentList[position] - - override fun getItemCount(): Int = currentList.size - - val itemComparator = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: LocalBaseItem, newItem: LocalBaseItem): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: LocalBaseItem, newItem: LocalBaseItem): Boolean = oldItem == newItem - override fun getChangePayload(oldItem: LocalBaseItem, newItem: LocalBaseItem) = newItem - } - - companion object { - const val TYPE_SONG = 0 - const val TYPE_ARTIST = 1 - const val TYPE_ALBUM = 2 - const val TYPE_PLAYLIST = 3 - const val TYPE_LIKED_PLAYLIST = 4 - const val TYPE_DOWNLOADED_PLAYLIST = 5 - const val TYPE_SONG_HEADER = 6 - const val TYPE_ARTIST_HEADER = 7 - const val TYPE_ALBUM_HEADER = 8 - const val TYPE_PLAYLIST_HEADER = 9 - const val TYPE_PLAYLIST_SONG_HEADER = 10 - const val TYPE_TEXT_HEADER = 11 - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/LoadStateAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/LoadStateAdapter.kt deleted file mode 100644 index a37973175..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/LoadStateAdapter.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.view.ViewGroup -import androidx.paging.LoadState -import androidx.paging.LoadStateAdapter -import com.zionhuang.music.R -import com.zionhuang.music.extensions.inflateWithBinding -import com.zionhuang.music.ui.viewholders.LoadStateViewHolder - -class LoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter() { - override fun onBindViewHolder(holder: LoadStateViewHolder, loadState: LoadState) = - holder.bind(loadState) - - override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): LoadStateViewHolder = - LoadStateViewHolder(parent.inflateWithBinding(R.layout.layout_load_state), retry) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/LocalItemAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/LocalItemAdapter.kt deleted file mode 100644 index d790c512d..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/LocalItemAdapter.kt +++ /dev/null @@ -1,156 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.annotation.SuppressLint -import android.view.MotionEvent -import android.view.ViewGroup -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.SelectionTracker.SELECTION_CHANGED_MARKER -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.ListAdapter -import com.zionhuang.music.R -import com.zionhuang.music.db.entities.* -import com.zionhuang.music.extensions.inflateWithBinding -import com.zionhuang.music.models.sortInfo.* -import com.zionhuang.music.ui.listeners.* -import com.zionhuang.music.ui.viewholders.* -import com.zionhuang.music.utils.makeTimeString -import me.zhanghai.android.fastscroll.PopupTextProvider -import java.time.format.DateTimeFormatter - -class LocalItemAdapter : ListAdapter(ItemComparator()), PopupTextProvider { - var songMenuListener: ISongMenuListener? = null - var artistMenuListener: IArtistMenuListener? = null - var albumMenuListener: IAlbumMenuListener? = null - var playlistMenuListener: IPlaylistMenuListener? = null - var likedPlaylistMenuListener: LikedPlaylistMenuListener? = null - var downloadedPlaylistMenuListener: DownloadedPlaylistMenuListener? = null - - var tracker: SelectionTracker? = null - var allowMoreAction: Boolean = true // for choosing playlist - var onShuffle: () -> Unit = {} - - var itemTouchHelper: ItemTouchHelper? = null - var isDraggable: Boolean = false // for reorder playlist - - override fun onBindViewHolder(holder: LocalItemViewHolder, position: Int) { - val item = getItem(position) ?: return - when (holder) { - is SongViewHolder -> holder.bind(item as Song, tracker?.isSelected(getItem(position).id) ?: false) - is ArtistViewHolder -> holder.bind(item as Artist, tracker?.isSelected(getItem(position).id) ?: false) - is AlbumViewHolder -> holder.bind(item as Album, tracker?.isSelected(getItem(position).id) ?: false) - is PlaylistViewHolder -> holder.bind(item as Playlist, tracker?.isSelected(getItem(position).id) ?: false) - is CustomPlaylistViewHolder -> when (item) { - is LikedPlaylist -> holder.bind(item, likedPlaylistMenuListener) - is DownloadedPlaylist -> holder.bind(item, downloadedPlaylistMenuListener) - else -> {} - } - is SongHeaderViewHolder -> holder.bind(item as SongHeader) - is ArtistHeaderViewHolder -> holder.bind(item as ArtistHeader) - is AlbumHeaderViewHolder -> holder.bind(item as AlbumHeader) - is PlaylistHeaderViewHolder -> holder.bind(item as PlaylistHeader) - is PlaylistSongHeaderViewHolder -> holder.bind(item as PlaylistSongHeader) - is TextHeaderViewHolder -> holder.bind(item as TextHeader) - } - } - - override fun onBindViewHolder(holder: LocalItemViewHolder, position: Int, payloads: MutableList) { - val payload = payloads.firstOrNull() - when { - payload is SongHeader && holder is SongHeaderViewHolder -> holder.bind(payload, true) - payload is ArtistHeader && holder is ArtistHeaderViewHolder -> holder.bind(payload, true) - payload is AlbumHeader && holder is AlbumHeaderViewHolder -> holder.bind(payload, true) - payload is PlaylistHeader && holder is PlaylistHeaderViewHolder -> holder.bind(payload, true) - payload is PlaylistSongHeader && holder is PlaylistSongHeaderViewHolder -> holder.bind(payload) - payload is TextHeader && holder is TextHeaderViewHolder -> holder.bind(payload) - payload == SELECTION_CHANGED_MARKER -> holder.onSelectionChanged(tracker?.isSelected(getItem(position).id) ?: false) - else -> onBindViewHolder(holder, position) - } - } - - @SuppressLint("ClickableViewAccessibility") - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LocalItemViewHolder = when (viewType) { - TYPE_SONG -> SongViewHolder(parent.inflateWithBinding(R.layout.item_song), songMenuListener, isDraggable).apply { - binding.dragHandle.setOnTouchListener { _, event -> - if (tracker?.hasSelection() == false && event.actionMasked == MotionEvent.ACTION_DOWN) itemTouchHelper?.startDrag(this) - true - } - } - TYPE_ARTIST -> ArtistViewHolder(parent.inflateWithBinding(R.layout.item_artist), artistMenuListener) - TYPE_ALBUM -> AlbumViewHolder(parent.inflateWithBinding(R.layout.item_album), albumMenuListener) - TYPE_PLAYLIST -> PlaylistViewHolder(parent.inflateWithBinding(R.layout.item_playlist), playlistMenuListener, allowMoreAction) - TYPE_LIKED_PLAYLIST, TYPE_DOWNLOADED_PLAYLIST -> CustomPlaylistViewHolder(parent.inflateWithBinding(R.layout.item_custom_playlist)) - TYPE_SONG_HEADER -> SongHeaderViewHolder(parent.inflateWithBinding(R.layout.item_header), onShuffle) - TYPE_ARTIST_HEADER -> ArtistHeaderViewHolder(parent.inflateWithBinding(R.layout.item_header)) - TYPE_ALBUM_HEADER -> AlbumHeaderViewHolder(parent.inflateWithBinding(R.layout.item_header)) - TYPE_PLAYLIST_HEADER -> PlaylistHeaderViewHolder(parent.inflateWithBinding(R.layout.item_header)) - TYPE_PLAYLIST_SONG_HEADER -> PlaylistSongHeaderViewHolder(parent.inflateWithBinding(R.layout.item_playlist_header), onShuffle) - TYPE_TEXT_HEADER -> TextHeaderViewHolder(parent.inflateWithBinding(R.layout.item_text_header)) - else -> error("Unknown view type") - } - - override fun getItemViewType(position: Int): Int = when (getItem(position)!!) { - is Song -> TYPE_SONG - is Artist -> TYPE_ARTIST - is Album -> TYPE_ALBUM - is Playlist -> TYPE_PLAYLIST - is LikedPlaylist -> TYPE_LIKED_PLAYLIST - is DownloadedPlaylist -> TYPE_DOWNLOADED_PLAYLIST - is SongHeader -> TYPE_SONG_HEADER - is ArtistHeader -> TYPE_ARTIST_HEADER - is AlbumHeader -> TYPE_ALBUM_HEADER - is PlaylistHeader -> TYPE_PLAYLIST_HEADER - is PlaylistSongHeader -> TYPE_PLAYLIST_SONG_HEADER - is TextHeader -> TYPE_TEXT_HEADER - } - - override fun getPopupText(position: Int): String = when (val item = getItem(position)) { - is SongHeader, is ArtistHeader, is AlbumHeader, is PlaylistHeader, is PlaylistSongHeader, is TextHeader -> "#" - is Song -> when (SongSortInfoPreference.type) { - SongSortType.CREATE_DATE -> item.song.createDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) - SongSortType.NAME -> item.song.title.substring(0, 1) - SongSortType.ARTIST -> item.artists.firstOrNull()?.name - SongSortType.PLAY_TIME -> makeTimeString(item.song.totalPlayTime) - } - is Artist -> when (ArtistSortInfoPreference.type) { - ArtistSortType.CREATE_DATE -> item.artist.createDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) - ArtistSortType.NAME -> item.artist.name.substring(0, 1) - ArtistSortType.SONG_COUNT -> item.songCount.toString() - } - is Album -> when (AlbumSortInfoPreference.type) { - AlbumSortType.CREATE_DATE -> item.album.createDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) - AlbumSortType.NAME -> item.album.title.substring(0, 1) - AlbumSortType.ARTIST -> item.artists.firstOrNull()?.name - AlbumSortType.YEAR -> item.album.year?.toString() - AlbumSortType.SONG_COUNT -> item.album.songCount.toString() - AlbumSortType.LENGTH -> makeTimeString(item.album.duration.toLong() * 1000) - } - is Playlist -> when (PlaylistSortInfoPreference.type) { - PlaylistSortType.CREATE_DATE -> item.playlist.createDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) - PlaylistSortType.NAME -> item.playlist.name.substring(0, 1) - PlaylistSortType.SONG_COUNT -> item.songCount.toString() - } - else -> "" - } ?: "" - - class ItemComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: LocalBaseItem, newItem: LocalBaseItem): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: LocalBaseItem, newItem: LocalBaseItem): Boolean = oldItem == newItem - override fun getChangePayload(oldItem: LocalBaseItem, newItem: LocalBaseItem) = newItem - } - - companion object { - const val TYPE_SONG = 0 - const val TYPE_ARTIST = 1 - const val TYPE_ALBUM = 2 - const val TYPE_PLAYLIST = 3 - const val TYPE_LIKED_PLAYLIST = 4 - const val TYPE_DOWNLOADED_PLAYLIST = 5 - const val TYPE_SONG_HEADER = 6 - const val TYPE_ARTIST_HEADER = 7 - const val TYPE_ALBUM_HEADER = 8 - const val TYPE_PLAYLIST_HEADER = 9 - const val TYPE_PLAYLIST_SONG_HEADER = 10 - const val TYPE_TEXT_HEADER = 11 - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/LyricsAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/LyricsAdapter.kt deleted file mode 100644 index c1576235d..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/LyricsAdapter.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.extensions.inflateWithBinding -import com.zionhuang.music.ui.viewholders.LyricsItemViewHolder -import com.zionhuang.music.utils.lyrics.LyricsHelper - -class LyricsAdapter : RecyclerView.Adapter() { - var items = emptyList() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LyricsItemViewHolder = - LyricsItemViewHolder(parent.inflateWithBinding(R.layout.item_lyrics)) - - - override fun onBindViewHolder(holder: LyricsItemViewHolder, position: Int) { - holder.bind(items[position]) - } - - override fun getItemCount(): Int = items.size -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/QueueItemAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/QueueItemAdapter.kt deleted file mode 100644 index cbfacbb16..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/QueueItemAdapter.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.annotation.SuppressLint -import android.support.v4.media.session.MediaSessionCompat.QueueItem -import android.view.MotionEvent.ACTION_DOWN -import android.view.ViewGroup -import androidx.annotation.IntRange -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.extensions.inflateWithBinding -import com.zionhuang.music.extensions.swap -import com.zionhuang.music.ui.viewholders.QueueItemViewHolder -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.withContext - - -class QueueItemAdapter(private val itemTouchHelper: ItemTouchHelper) : RecyclerView.Adapter() { - private var currentList: MutableList = mutableListOf() - private val diffCallback = QueueItemComparator() - - override fun onBindViewHolder(holder: QueueItemViewHolder, position: Int) = holder.bind(getItem(position)) - - @SuppressLint("ClickableViewAccessibility") - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QueueItemViewHolder = - QueueItemViewHolder(parent.inflateWithBinding(R.layout.item_queue)).apply { - binding.dragHandle.setOnTouchListener { _, event -> - if (event.actionMasked == ACTION_DOWN) { - itemTouchHelper.startDrag(this) - } - true - } - } - - fun moveItem(from: Int, to: Int) { - currentList.swap(from, to) - notifyItemMoved(from, to) - } - - fun removeItem(index: Int) { - currentList.removeAt(index) - notifyItemRemoved(index) - } - - suspend fun submitData(newList: List) { - val oldList: List = currentList - val result = withContext(IO) { - DiffUtil.calculateDiff(object : DiffUtil.Callback() { - override fun getOldListSize(): Int = oldList.size - override fun getNewListSize(): Int = newList.size - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = diffCallback.areItemsTheSame(oldList[oldItemPosition], newList[newItemPosition]) - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = diffCallback.areContentsTheSame(oldList[oldItemPosition], newList[newItemPosition]) - }) - } - currentList = newList.toMutableList() - result.dispatchUpdatesTo(this) - } - - fun getItem(@IntRange(from = 0) position: Int): QueueItem = currentList[position] - - override fun getItemCount(): Int = currentList.size - - class QueueItemComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: QueueItem, newItem: QueueItem): Boolean = oldItem.description.mediaId == newItem.description.mediaId - override fun areContentsTheSame(oldItem: QueueItem, newItem: QueueItem): Boolean = - oldItem.description.title.toString() == newItem.description.title.toString() && - oldItem.description.subtitle.toString() == newItem.description.subtitle.toString() - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/YouTubeItemAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/YouTubeItemAdapter.kt deleted file mode 100644 index 382bef0a0..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/YouTubeItemAdapter.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import androidx.core.view.updateLayoutParams -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.ListAdapter -import com.zionhuang.innertube.models.* -import com.zionhuang.music.ui.viewholders.* -import com.zionhuang.music.utils.NavigationEndpointHandler - -class YouTubeItemAdapter( - private val navigationEndpointHandler: NavigationEndpointHandler, - private val itemViewType: YTBaseItem.ViewType = YTBaseItem.ViewType.LIST, - private val forceMatchParent: Boolean = false, -) : ListAdapter>(ItemComparator()) { - var onFillQuery: (String) -> Unit = {} - var onSearch: (String) -> Unit = {} - var onRefreshSuggestions: () -> Unit = {} - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): YouTubeViewHolder<*> = when (viewType) { - BASE_ITEM_HEADER -> YouTubeHeaderViewHolder(parent, navigationEndpointHandler) - BASE_ITEM_HEADER_ARTIST -> YouTubeArtistHeaderViewHolder(parent, navigationEndpointHandler) - BASE_ITEM_HEADER_ALBUM_OR_PLAYLIST -> YouTubeAlbumOrPlaylistHeaderViewHolder(parent, navigationEndpointHandler) - BASE_ITEM_CAROUSEL, BASE_ITEM_GRID -> YouTubeItemContainerViewHolder(parent, navigationEndpointHandler) - BASE_ITEM_DESCRIPTION -> YouTubeDescriptionViewHolder(parent) - BASE_ITEM_SEPARATOR -> YouTubeSeparatorViewHolder(parent) - BASE_ITEM_NAVIGATION -> when (itemViewType) { - YTBaseItem.ViewType.LIST -> YouTubeNavigationItemViewHolder(parent, navigationEndpointHandler) - YTBaseItem.ViewType.BLOCK -> YouTubeNavigationTileViewHolder(parent, navigationEndpointHandler) - } - BASE_ITEM_SUGGESTION -> YouTubeSuggestionViewHolder(parent, onFillQuery, onSearch, onRefreshSuggestions) - ITEM -> when (itemViewType) { - YTBaseItem.ViewType.LIST -> YouTubeListItemViewHolder(parent, navigationEndpointHandler) - YTBaseItem.ViewType.BLOCK -> YouTubeSquareItemViewHolder(parent, navigationEndpointHandler) - } - else -> throw IllegalArgumentException("Unknown view type") - }.apply { - if (forceMatchParent) { - binding.root.updateLayoutParams { - width = MATCH_PARENT - } - } - } - - override fun onBindViewHolder(holder: YouTubeViewHolder<*>, position: Int) { - val item = getItem(position) - when (holder) { - is YouTubeHeaderViewHolder -> holder.bind(item as Header) - is YouTubeArtistHeaderViewHolder -> holder.bind(item as ArtistHeader) - is YouTubeAlbumOrPlaylistHeaderViewHolder -> holder.bind(item as AlbumOrPlaylistHeader) - is YouTubeItemContainerViewHolder -> holder.bind(item) - is YouTubeDescriptionViewHolder -> holder.bind(item as DescriptionSection) - is YouTubeSeparatorViewHolder -> {} - is YouTubeNavigationItemViewHolder -> holder.bind(item as NavigationItem) - is YouTubeNavigationTileViewHolder -> holder.bind(item as NavigationItem) - is YouTubeSuggestionViewHolder -> holder.bind(item as SuggestionTextItem) - is YouTubeListItemViewHolder -> holder.bind(item as YTItem) - is YouTubeSquareItemViewHolder -> holder.bind(item as YTItem) - } - } - - override fun getItemViewType(position: Int): Int = when (getItem(position)) { - is Header -> BASE_ITEM_HEADER - is ArtistHeader -> BASE_ITEM_HEADER_ARTIST - is AlbumOrPlaylistHeader -> BASE_ITEM_HEADER_ALBUM_OR_PLAYLIST - is CarouselSection -> BASE_ITEM_CAROUSEL - is GridSection -> BASE_ITEM_GRID - is DescriptionSection -> BASE_ITEM_DESCRIPTION - Separator -> BASE_ITEM_SEPARATOR - is NavigationItem -> BASE_ITEM_NAVIGATION - is SuggestionTextItem -> BASE_ITEM_SUGGESTION - else -> ITEM - } - - - class ItemComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: YTBaseItem, newItem: YTBaseItem): Boolean = oldItem::class == newItem::class && oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: YTBaseItem, newItem: YTBaseItem): Boolean = oldItem == newItem - } - - companion object { - const val BASE_ITEM_HEADER = 1 - const val BASE_ITEM_HEADER_ARTIST = 2 - const val BASE_ITEM_HEADER_ALBUM_OR_PLAYLIST = 3 - const val BASE_ITEM_CAROUSEL = 4 - const val BASE_ITEM_GRID = 5 - const val BASE_ITEM_DESCRIPTION = 6 - const val BASE_ITEM_SEPARATOR = 7 - const val BASE_ITEM_NAVIGATION = 8 - const val BASE_ITEM_SUGGESTION = 9 - const val ITEM = 10 - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/YouTubeItemPagingAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/YouTubeItemPagingAdapter.kt deleted file mode 100644 index 3d6c15475..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/YouTubeItemPagingAdapter.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import androidx.core.view.updateLayoutParams -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.SelectionTracker.SELECTION_CHANGED_MARKER -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.GridLayoutManager -import com.zionhuang.innertube.models.* -import com.zionhuang.music.ui.viewholders.* -import com.zionhuang.music.utils.NavigationEndpointHandler - -/** - * Same as [YouTubeItemAdapter], but extends [PagingDataAdapter] - */ -class YouTubeItemPagingAdapter( - private val navigationEndpointHandler: NavigationEndpointHandler, - private val itemViewType: YTBaseItem.ViewType = YTBaseItem.ViewType.LIST, - private val forceMatchParent: Boolean = false, -) : PagingDataAdapter>(ItemComparator()) { - var tracker: SelectionTracker? = null - var onFillQuery: (String) -> Unit = {} - var onSearch: (String) -> Unit = {} - var onRefreshSuggestions: () -> Unit = {} - var onPlayAlbum: (() -> Unit)? = null - var onShuffleAlbum: (() -> Unit)? = null - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): YouTubeViewHolder<*> = when (viewType) { - BASE_ITEM_HEADER -> YouTubeHeaderViewHolder(parent, navigationEndpointHandler) - BASE_ITEM_HEADER_ARTIST -> YouTubeArtistHeaderViewHolder(parent, navigationEndpointHandler) - BASE_ITEM_HEADER_ALBUM_OR_PLAYLIST -> YouTubeAlbumOrPlaylistHeaderViewHolder(parent, navigationEndpointHandler, onPlayAlbum, onShuffleAlbum) - BASE_ITEM_CAROUSEL, BASE_ITEM_GRID -> YouTubeItemContainerViewHolder(parent, navigationEndpointHandler) - BASE_ITEM_DESCRIPTION -> YouTubeDescriptionViewHolder(parent) - BASE_ITEM_SEPARATOR -> YouTubeSeparatorViewHolder(parent) - BASE_ITEM_NAVIGATION -> when (itemViewType) { - YTBaseItem.ViewType.LIST -> YouTubeNavigationItemViewHolder(parent, navigationEndpointHandler) - YTBaseItem.ViewType.BLOCK -> YouTubeNavigationTileViewHolder(parent, navigationEndpointHandler) - } - BASE_ITEM_SUGGESTION -> YouTubeSuggestionViewHolder(parent, onFillQuery, onSearch, onRefreshSuggestions) - ITEM -> when (itemViewType) { - YTBaseItem.ViewType.LIST -> YouTubeListItemViewHolder(parent, navigationEndpointHandler) - YTBaseItem.ViewType.BLOCK -> YouTubeSquareItemViewHolder(parent, navigationEndpointHandler) - } - else -> throw IllegalArgumentException("Unknown view type") - }.apply { - if (forceMatchParent) { - binding.root.updateLayoutParams { - width = MATCH_PARENT - } - } - } - - override fun onBindViewHolder(holder: YouTubeViewHolder<*>, position: Int) { - getItem(position)?.let { item -> - when (holder) { - is YouTubeHeaderViewHolder -> holder.bind(item as Header) - is YouTubeArtistHeaderViewHolder -> holder.bind(item as ArtistHeader) - is YouTubeAlbumOrPlaylistHeaderViewHolder -> holder.bind(item as AlbumOrPlaylistHeader) - is YouTubeItemContainerViewHolder -> holder.bind(item) - is YouTubeDescriptionViewHolder -> holder.bind(item as DescriptionSection) - is YouTubeSeparatorViewHolder -> {} - is YouTubeNavigationItemViewHolder -> holder.bind(item as NavigationItem) - is YouTubeNavigationTileViewHolder -> holder.bind(item as NavigationItem) - is YouTubeSuggestionViewHolder -> holder.bind(item as SuggestionTextItem) - is YouTubeListItemViewHolder -> holder.bind(item as YTItem, tracker?.isSelected(item.id) ?: false) - is YouTubeSquareItemViewHolder -> holder.bind(item as YTItem) - } - } - } - - override fun onBindViewHolder(holder: YouTubeViewHolder<*>, position: Int, payloads: MutableList) { - val payload = payloads.firstOrNull() - when { - payload == SELECTION_CHANGED_MARKER && holder is YouTubeListItemViewHolder -> holder.onSelectionChanged(tracker?.isSelected(getItem(position)!!.id) ?: false) - else -> onBindViewHolder(holder, position) - } - } - - override fun getItemViewType(position: Int): Int = when (getItem(position)) { - is Header -> BASE_ITEM_HEADER - is ArtistHeader -> BASE_ITEM_HEADER_ARTIST - is AlbumOrPlaylistHeader -> BASE_ITEM_HEADER_ALBUM_OR_PLAYLIST - is CarouselSection -> BASE_ITEM_CAROUSEL - is GridSection -> BASE_ITEM_GRID - is DescriptionSection -> BASE_ITEM_DESCRIPTION - Separator -> BASE_ITEM_SEPARATOR - is NavigationItem -> BASE_ITEM_NAVIGATION - is SuggestionTextItem -> BASE_ITEM_SUGGESTION - else -> ITEM - } - - fun getItemAt(position: Int) = getItem(position) - - class ItemComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: YTBaseItem, newItem: YTBaseItem) = oldItem::class == newItem::class && oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: YTBaseItem, newItem: YTBaseItem) = oldItem == newItem - override fun getChangePayload(oldItem: YTBaseItem, newItem: YTBaseItem) = newItem - } - - companion object { - const val BASE_ITEM_HEADER = 1 - const val BASE_ITEM_HEADER_ARTIST = 2 - const val BASE_ITEM_HEADER_ALBUM_OR_PLAYLIST = 3 - const val BASE_ITEM_CAROUSEL = 4 - const val BASE_ITEM_GRID = 5 - const val BASE_ITEM_DESCRIPTION = 6 - const val BASE_ITEM_SEPARATOR = 7 - const val BASE_ITEM_NAVIGATION = 8 - const val BASE_ITEM_SUGGESTION = 9 - const val ITEM = 10 - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/selection/LocalItemDetailsLookup.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/selection/LocalItemDetailsLookup.kt deleted file mode 100644 index 54cc0438d..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/selection/LocalItemDetailsLookup.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zionhuang.music.ui.adapters.selection - -import android.view.MotionEvent -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.ui.viewholders.LocalItemViewHolder - -class LocalItemDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup() { - override fun getItemDetails(e: MotionEvent): ItemDetails? = recyclerView.findChildViewUnder(e.x, e.y)?.let { v -> - (recyclerView.getChildViewHolder(v) as? LocalItemViewHolder)?.itemDetails - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/selection/LocalItemKeyProvider.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/selection/LocalItemKeyProvider.kt deleted file mode 100644 index bcf502c57..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/selection/LocalItemKeyProvider.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.zionhuang.music.ui.adapters.selection - -import androidx.recyclerview.selection.ItemKeyProvider -import com.zionhuang.music.db.entities.LocalItem -import com.zionhuang.music.ui.adapters.DraggableLocalItemAdapter -import com.zionhuang.music.ui.adapters.LocalItemAdapter - -class LocalItemKeyProvider(private val adapter: LocalItemAdapter) : ItemKeyProvider(SCOPE_MAPPED) { - override fun getKey(position: Int): String? = adapter.currentList.getOrNull(position)?.takeIf { it is LocalItem }?.id - override fun getPosition(key: String): Int = adapter.currentList.indexOfFirst { it.id == key } -} - -class DraggableLocalItemKeyProvider(private val adapter: DraggableLocalItemAdapter) : ItemKeyProvider(SCOPE_MAPPED) { - override fun getKey(position: Int): String? = adapter.currentList.getOrNull(position)?.takeIf { it is LocalItem }?.id - override fun getPosition(key: String): Int = adapter.currentList.indexOfFirst { it.id == key } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/selection/YouTubeItemDetailsLookup.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/selection/YouTubeItemDetailsLookup.kt deleted file mode 100644 index fb6447fc6..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/selection/YouTubeItemDetailsLookup.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zionhuang.music.ui.adapters.selection - -import android.view.MotionEvent -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.ui.viewholders.YouTubeListItemViewHolder - -class YouTubeItemDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup() { - override fun getItemDetails(e: MotionEvent): ItemDetails? = recyclerView.findChildViewUnder(e.x, e.y)?.let { v -> - (recyclerView.getChildViewHolder(v) as? YouTubeListItemViewHolder)?.itemDetails - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/selection/YouTubeItemKeyProvider.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/selection/YouTubeItemKeyProvider.kt deleted file mode 100644 index bfdd08476..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/selection/YouTubeItemKeyProvider.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zionhuang.music.ui.adapters.selection - -import androidx.recyclerview.selection.ItemKeyProvider -import com.zionhuang.innertube.models.ArtistItem -import com.zionhuang.innertube.models.YTItem -import com.zionhuang.music.ui.adapters.YouTubeItemPagingAdapter - -class YouTubeItemKeyProvider(private val adapter: YouTubeItemPagingAdapter) : ItemKeyProvider(SCOPE_CACHED) { - override fun getKey(position: Int): String? = adapter.snapshot().getOrNull(position)?.takeIf { it is YTItem && it !is ArtistItem }?.id - override fun getPosition(key: String): Int = adapter.snapshot().indexOfFirst { it?.id == key } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/bindings/Bindings.kt b/app/src/main/java/com/zionhuang/music/ui/bindings/Bindings.kt deleted file mode 100644 index cb97dcede..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/bindings/Bindings.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.zionhuang.music.ui.bindings - -import android.graphics.drawable.Drawable -import android.support.v4.media.session.PlaybackStateCompat.* -import android.view.View -import android.widget.ImageView -import android.widget.TextView -import androidx.databinding.BindingAdapter -import coil.load -import coil.size.Scale -import coil.transform.CircleCropTransformation -import coil.transform.RoundedCornersTransformation -import com.zionhuang.innertube.models.Thumbnail -import com.zionhuang.music.extensions.getDensity -import com.zionhuang.music.ui.widgets.PlayPauseButton -import com.zionhuang.music.ui.widgets.RepeatButton -import com.zionhuang.music.ui.widgets.ShuffleButton -import com.zionhuang.music.utils.makeTimeString -import kotlin.math.roundToInt - -@BindingAdapter("enabled") -fun setEnabled(view: View, enabled: Boolean) { - view.isEnabled = enabled - view.alpha = if (enabled) 1f else 0.5f -} - -@BindingAdapter("duration") -fun setDuration(view: TextView, duration: Long) { - view.text = makeTimeString(duration) -} - -@BindingAdapter("playState") -fun setPlayState(view: PlayPauseButton, @State state: Int) { - if (state == STATE_PAUSED || state == STATE_ERROR || state == STATE_NONE) { - view.animationPause() - } else if (state == STATE_PLAYING) { - view.animatePlay() - } -} - -@BindingAdapter("shuffleMode") -fun setShuffleMode(view: ShuffleButton, @ShuffleMode mode: Int) { - when (mode) { - SHUFFLE_MODE_NONE, SHUFFLE_MODE_INVALID -> view.disable() - SHUFFLE_MODE_ALL, SHUFFLE_MODE_GROUP -> view.enable() - } -} - -@BindingAdapter("repeatMode") -fun setRepeatMode(view: RepeatButton, @RepeatMode state: Int) { - view.setState(state) -} - -@BindingAdapter("srcUrl", "cornerRadius", "circleCrop", "placeholder", "thumbnailWidth", "thumbnailHeight", requireAll = false) -fun setImageUrl( - view: ImageView, - url: String?, - cornerRadius: Float?, - circleCrop: Boolean?, - placeholder: Drawable?, - thumbnailWidth: Float?, - thumbnailHeight: Float?, -) { - val density = view.context.getDensity() - val resizedUrl = if (url != null) resizeThumbnailUrl(url, thumbnailWidth?.let { (it * density).roundToInt() }, thumbnailHeight?.let { (it * density).roundToInt() }) else null - view.load(resizedUrl) { - crossfade(true) - scale(Scale.FIT) - // the order of the following two lines is important. If circleCrop, ignore cornerRadius - if (cornerRadius != null) transformations(RoundedCornersTransformation(cornerRadius)) - if (circleCrop == true) transformations(CircleCropTransformation()) - if (placeholder != null) { - placeholder(placeholder) - error(placeholder) - } - } -} - -@BindingAdapter("thumbnails", "cornerRadius", "circleCrop", "placeholder", "thumbnailWidth", "thumbnailHeight", requireAll = false) -fun setThumbnails( - view: ImageView, - thumbnails: List?, - cornerRadius: Float?, - circleCrop: Boolean?, - placeholder: Drawable?, - thumbnailWidth: Float?, - thumbnailHeight: Float?, -) = setImageUrl(view, thumbnails?.lastOrNull()?.url, cornerRadius, circleCrop, placeholder, thumbnailWidth, thumbnailHeight) - -fun resizeThumbnailUrl(url: String, width: Int?, height: Int?): String { - if (width == null && height == null) return url - "https://lh3\\.googleusercontent\\.com/.*=w(\\d+)-h(\\d+).*".toRegex().matchEntire(url)?.groupValues?.let { group -> - val (W, H) = group.drop(1).map { it.toInt() } - var w = width - var h = height - if (w != null && h == null) h = (w / W) * H - if (w == null && h != null) w = (h / H) * W - return "$url-w$w-h$h" - } - if (url matches "https://yt3\\.ggpht\\.com/.*=s(\\d+)".toRegex()) { - return "$url-s${width ?: height}" - } - return url -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/component/AutoResizeText.kt b/app/src/main/java/com/zionhuang/music/ui/component/AutoResizeText.kt new file mode 100644 index 000000000..89d6f51b8 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/AutoResizeText.kt @@ -0,0 +1,92 @@ +package com.zionhuang.music.ui.component + +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +/** + * From https://stackoverflow.com/a/69780826 + */ +@Composable +fun AutoResizeText( + text: String, + fontSizeRange: FontSizeRange, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + style: TextStyle = LocalTextStyle.current, +) { + var fontSizeValue by remember { mutableStateOf(fontSizeRange.max.value) } + var readyToDraw by remember { mutableStateOf(false) } + + Text( + text = text, + color = color, + maxLines = maxLines, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + style = style, + fontSize = fontSizeValue.sp, + onTextLayout = { + if (it.didOverflowHeight && !readyToDraw) { + // Did Overflow height, calculate next font size value + val nextFontSizeValue = fontSizeValue - fontSizeRange.step.value + if (nextFontSizeValue <= fontSizeRange.min.value) { + // Reached minimum, set minimum font size and it's readToDraw + fontSizeValue = fontSizeRange.min.value + readyToDraw = true + } else { + // Text doesn't fit yet and haven't reached minimum text range, keep decreasing + fontSizeValue = nextFontSizeValue + } + } else { + // Text fits before reaching the minimum, it's readyToDraw + readyToDraw = true + } + }, + modifier = modifier.drawWithContent { if (readyToDraw) drawContent() } + ) +} + +data class FontSizeRange( + val min: TextUnit, + val max: TextUnit, + val step: TextUnit = DEFAULT_TEXT_STEP, +) { + init { + require(min < max) { "min should be less than max, $this" } + require(step.value > 0) { "step should be greater than 0, $this" } + } + + companion object { + private val DEFAULT_TEXT_STEP = 1.sp + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/component/BigSeekBar.kt b/app/src/main/java/com/zionhuang/music/ui/component/BigSeekBar.kt new file mode 100644 index 000000000..09d2b8ecf --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/BigSeekBar.kt @@ -0,0 +1,50 @@ +package com.zionhuang.music.ui.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.unit.dp + +@Composable +fun BigSeekBar( + progressProvider: () -> Float, + onProgressChange: (Float) -> Unit, + modifier: Modifier = Modifier, + background: Color = MaterialTheme.colorScheme.surfaceTint.copy(alpha = 0.13f), + color: Color = MaterialTheme.colorScheme.primary, +) { + var width by remember { + mutableStateOf(0f) + } + + Canvas( + modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(16.dp)) + .onPlaced { + width = it.size.width.toFloat() + } + .pointerInput(progressProvider) { + detectHorizontalDragGestures { _, dragAmount -> + onProgressChange((progressProvider() + dragAmount * 1.2f / width).coerceIn(0f, 1f)) + } + } + ) { + drawRect(color = background) + + drawRect( + color = color, + size = size.copy(width = size.width * progressProvider()) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/component/BottomSheet.kt b/app/src/main/java/com/zionhuang/music/ui/component/BottomSheet.kt new file mode 100644 index 000000000..d345d2000 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/BottomSheet.kt @@ -0,0 +1,314 @@ +package com.zionhuang.music.ui.component + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.input.pointer.util.addPointerInputChange +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import com.zionhuang.music.constants.NavigationBarAnimationSpec +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Bottom Sheet + * Modified from [ViMusic](https://github.com/vfsfitvnm/ViMusic) + */ +@Composable +fun BottomSheet( + state: BottomSheetState, + modifier: Modifier = Modifier, + backgroundColor: Color = MaterialTheme.colorScheme.surface, + onDismiss: (() -> Unit)? = null, + collapsedContent: @Composable BoxScope.() -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize() + .offset { + val y = (state.expandedBound - state.value) + .roundToPx() + .coerceAtLeast(0) + IntOffset(x = 0, y = y) + } + .pointerInput(state) { + val velocityTracker = VelocityTracker() + + detectVerticalDragGestures( + onVerticalDrag = { change, dragAmount -> + velocityTracker.addPointerInputChange(change) + state.dispatchRawDelta(dragAmount) + }, + onDragCancel = { + velocityTracker.resetTracking() + state.snapTo(state.collapsedBound) + }, + onDragEnd = { + val velocity = -velocityTracker.calculateVelocity().y + velocityTracker.resetTracking() + state.performFling(velocity, onDismiss) + } + ) + } + .background(backgroundColor) + ) { + if (!state.isCollapsed && !state.isDismissed) { + BackHandler(onBack = state::collapseSoft) + } + + if (!state.isCollapsed) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = ((state.progress - 0.25f) * 4).coerceIn(0f, 1f) + }, + content = content + ) + } + + if (!state.isExpanded && (onDismiss == null || !state.isDismissed)) { + Box( + modifier = Modifier + .graphicsLayer { + alpha = 1f - (state.progress * 4).coerceAtMost(1f) + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = state::expandSoft + ) + .fillMaxWidth() + .height(state.collapsedBound), + content = collapsedContent + ) + } + } +} + +@Stable +class BottomSheetState( + draggableState: DraggableState, + private val coroutineScope: CoroutineScope, + private val animatable: Animatable, + private val onAnchorChanged: (Int) -> Unit, + val collapsedBound: Dp, +) : DraggableState by draggableState { + val dismissedBound: Dp + get() = animatable.lowerBound!! + + val expandedBound: Dp + get() = animatable.upperBound!! + + val value by animatable.asState() + + val isDismissed by derivedStateOf { + value == animatable.lowerBound!! + } + + val isCollapsed by derivedStateOf { + value == collapsedBound + } + + val isExpanded by derivedStateOf { + value == animatable.upperBound + } + + val progress by derivedStateOf { + 1f - (animatable.upperBound!! - animatable.value) / (animatable.upperBound!! - collapsedBound) + } + + fun collapse(animationSpec: AnimationSpec) { + onAnchorChanged(collapsedAnchor) + coroutineScope.launch { + animatable.animateTo(collapsedBound, animationSpec) + } + } + + fun expand(animationSpec: AnimationSpec) { + onAnchorChanged(expandedAnchor) + coroutineScope.launch { + animatable.animateTo(animatable.upperBound!!, animationSpec) + } + } + + private fun collapse() { + collapse(SpringSpec()) + } + + private fun expand() { + expand(SpringSpec()) + } + + fun collapseSoft() { + collapse(spring(stiffness = Spring.StiffnessMediumLow)) + } + + fun expandSoft() { + expand(spring(stiffness = Spring.StiffnessMediumLow)) + } + + fun dismiss() { + onAnchorChanged(dismissedAnchor) + coroutineScope.launch { + animatable.animateTo(animatable.lowerBound!!) + } + } + + fun snapTo(value: Dp) { + coroutineScope.launch { + animatable.snapTo(value) + } + } + + fun performFling(velocity: Float, onDismiss: (() -> Unit)?) { + if (velocity > 250) { + expand() + } else if (velocity < -250) { + if (value < collapsedBound && onDismiss != null) { + dismiss() + onDismiss.invoke() + } else { + collapse() + } + } else { + val l0 = dismissedBound + val l1 = (collapsedBound - dismissedBound) / 2 + val l2 = (expandedBound - collapsedBound) / 2 + val l3 = expandedBound + + when (value) { + in l0..l1 -> { + if (onDismiss != null) { + dismiss() + onDismiss.invoke() + } else { + collapse() + } + } + in l1..l2 -> collapse() + in l2..l3 -> expand() + else -> Unit + } + } + } + + val preUpPostDownNestedScrollConnection + get() = object : NestedScrollConnection { + var isTopReached = false + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (isExpanded && available.y < 0) { + isTopReached = false + } + + return if (isTopReached && available.y < 0 && source == NestedScrollSource.Drag) { + dispatchRawDelta(available.y) + available + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (!isTopReached) { + isTopReached = consumed.y == 0f && available.y > 0 + } + + return if (isTopReached && source == NestedScrollSource.Drag) { + dispatchRawDelta(available.y) + available + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + return if (isTopReached) { + val velocity = -available.y + performFling(velocity, null) + + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + isTopReached = false + return Velocity.Zero + } + } +} + +const val expandedAnchor = 2 +const val collapsedAnchor = 1 +const val dismissedAnchor = 0 + +@Composable +fun rememberBottomSheetState( + dismissedBound: Dp, + expandedBound: Dp, + collapsedBound: Dp = dismissedBound, + initialAnchor: Int = dismissedAnchor, +): BottomSheetState { + val density = LocalDensity.current + val coroutineScope = rememberCoroutineScope() + + var previousAnchor by rememberSaveable { + mutableStateOf(initialAnchor) + } + val animatable = remember { + Animatable(0.dp, Dp.VectorConverter) + } + + return remember(dismissedBound, expandedBound, collapsedBound, coroutineScope) { + val initialValue = when (previousAnchor) { + expandedAnchor -> expandedBound + collapsedAnchor -> collapsedBound + dismissedAnchor -> dismissedBound + else -> error("Unknown BottomSheet anchor") + } + + animatable.updateBounds(dismissedBound.coerceAtMost(expandedBound), expandedBound) + coroutineScope.launch { + animatable.animateTo(initialValue, NavigationBarAnimationSpec) + } + + BottomSheetState( + draggableState = DraggableState { delta -> + coroutineScope.launch { + animatable.snapTo(animatable.value - with(density) { delta.toDp() }) + } + }, + onAnchorChanged = { previousAnchor = it }, + coroutineScope = coroutineScope, + animatable = animatable, + collapsedBound = collapsedBound + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt b/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt new file mode 100644 index 000000000..7130c45d8 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt @@ -0,0 +1,93 @@ +package com.zionhuang.music.ui.component + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.dp +import com.zionhuang.music.ui.utils.top + +val LocalMenuState = compositionLocalOf { MenuState() } + +@Stable +class MenuState( + isVisible: Boolean = false, + content: @Composable ColumnScope.() -> Unit = {}, +) { + var isVisible by mutableStateOf(isVisible) + var content by mutableStateOf(content) + + fun show(content: @Composable ColumnScope.() -> Unit) { + isVisible = true + this.content = content + } + + fun dismiss() { + isVisible = false + } +} + +@Composable +fun BottomSheetMenu( + modifier: Modifier = Modifier, + state: MenuState, + background: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation), +) { + val focusManager = LocalFocusManager.current + + AnimatedVisibility( + visible = state.isVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + BackHandler { + state.dismiss() + } + + Spacer( + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures { + state.dismiss() + } + } + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)) + .fillMaxSize() + ) + } + + AnimatedVisibility( + visible = state.isVisible, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = modifier + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(top = 48.dp) + .clip(ShapeDefaults.Large.top()) + .background(background) + ) { + state.content(this) + } + } + + LaunchedEffect(state.isVisible) { + if (state.isVisible) { + focusManager.clearFocus() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/component/Dialog.kt b/app/src/main/java/com/zionhuang/music/ui/component/Dialog.kt new file mode 100644 index 000000000..628d0afb2 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/Dialog.kt @@ -0,0 +1,187 @@ +package com.zionhuang.music.ui.component + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.delay + +@Composable +fun DefaultDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + title: (@Composable () -> Unit)? = null, + buttons: (@Composable RowScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier.padding(24.dp), + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .padding(24.dp) + ) { + if (icon != null) { + CompositionLocalProvider(LocalContentColor provides AlertDialogDefaults.iconContentColor) { + Box( + Modifier.align(Alignment.CenterHorizontally) + ) { + icon() + } + } + + Spacer(Modifier.height(16.dp)) + } + if (title != null) { + CompositionLocalProvider(LocalContentColor provides AlertDialogDefaults.titleContentColor) { + ProvideTextStyle(MaterialTheme.typography.headlineSmall) { + Box( + // Align the title to the center when an icon is present. + Modifier.align(if (icon == null) Alignment.Start else Alignment.CenterHorizontally) + ) { + title() + } + } + } + + Spacer(Modifier.height(16.dp)) + } + + content() + + if (buttons != null) { + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.align(Alignment.End) + ) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) { + ProvideTextStyle( + value = MaterialTheme.typography.labelLarge + ) { + buttons() + } + } + } + } + } + } + } +} + +@Composable +fun ListDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: LazyListScope.() -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier.padding(24.dp), + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.padding(vertical = 24.dp) + ) { + LazyColumn(content = content) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TextFieldDialog( + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + title: (@Composable () -> Unit)? = null, + initialTextFieldValue: TextFieldValue = TextFieldValue(), + placeholder: @Composable (() -> Unit)? = null, + singleLine: Boolean = true, + maxLines: Int = if (singleLine) 1 else 10, + isInputValid: (String) -> Boolean = { it.isNotEmpty() }, + onDone: (String) -> Unit, + onDismiss: () -> Unit, +) { + val (textFieldValue, onTextFieldValueChange) = remember { + mutableStateOf(initialTextFieldValue) + } + + val focusRequester = remember { + FocusRequester() + } + + LaunchedEffect(Unit) { + delay(300) + focusRequester.requestFocus() + } + + DefaultDialog( + onDismiss = onDismiss, + modifier = modifier, + icon = icon, + title = title, + buttons = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + enabled = isInputValid(textFieldValue.text), + onClick = { + onDismiss() + onDone(textFieldValue.text) + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) { + TextField( + value = textFieldValue, + onValueChange = onTextFieldValueChange, + placeholder = placeholder, + singleLine = singleLine, + maxLines = maxLines, + colors = TextFieldDefaults.outlinedTextFieldColors(), + keyboardOptions = KeyboardOptions(imeAction = if (singleLine) ImeAction.Done else ImeAction.None), + keyboardActions = KeyboardActions( + onDone = { + onDone(textFieldValue.text) + onDismiss() + } + ), + modifier = Modifier + .weight(weight = 1f, fill = false) + .focusRequester(focusRequester) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/component/EmptyPlaceholder.kt b/app/src/main/java/com/zionhuang/music/ui/component/EmptyPlaceholder.kt new file mode 100644 index 000000000..39eb78f69 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/EmptyPlaceholder.kt @@ -0,0 +1,41 @@ +package com.zionhuang.music.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp + +@Composable +fun EmptyPlaceholder( + @DrawableRes icon: Int, + text: String, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + .padding(12.dp) + ) { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(64.dp) + ) + + Spacer(Modifier.height(12.dp)) + + Text( + text = text, + style = MaterialTheme.typography.bodyLarge + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/GridMenu.kt b/app/src/main/java/com/zionhuang/music/ui/component/GridMenu.kt new file mode 100644 index 000000000..f79514784 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/GridMenu.kt @@ -0,0 +1,142 @@ +package com.zionhuang.music.ui.component + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.media3.exoplayer.offline.Download +import com.zionhuang.music.R + +val GridMenuItemHeight = 96.dp + +@Composable +fun GridMenu( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: LazyGridScope.() -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 120.dp), + modifier = modifier, + contentPadding = contentPadding, + content = content + ) +} + +fun LazyGridScope.GridMenuItem( + modifier: Modifier = Modifier, + @DrawableRes icon: Int, + @StringRes title: Int, + enabled: Boolean = true, + onClick: () -> Unit, +) = GridMenuItem( + modifier = modifier, + icon = { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + }, + title = title, + enabled = enabled, + onClick = onClick +) + +fun LazyGridScope.GridMenuItem( + modifier: Modifier = Modifier, + icon: @Composable BoxScope.() -> Unit, + @StringRes title: Int, + enabled: Boolean = true, + onClick: () -> Unit, +) { + item { + Column( + modifier = modifier + .clip(ShapeDefaults.Large) + .height(GridMenuItemHeight) + .clickable( + enabled = enabled, + onClick = onClick + ) + .alpha(if (enabled) 1f else 0.5f) + .padding(12.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + content = icon + ) + Text( + text = stringResource(title), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + maxLines = 2, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + + +fun LazyGridScope.DownloadGridMenu( + @Download.State state: Int?, + onRemoveDownload: () -> Unit, + onDownload: () -> Unit, +) { + when (state) { + Download.STATE_COMPLETED -> { + GridMenuItem( + icon = R.drawable.offline, + title = R.string.remove_download, + onClick = onRemoveDownload + ) + } + + Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { + GridMenuItem( + icon = { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + }, + title = R.string.downloading, + onClick = onRemoveDownload + ) + } + + else -> { + GridMenuItem( + icon = R.drawable.download, + title = R.string.download, + onClick = onDownload + ) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/IconButton.kt b/app/src/main/java/com/zionhuang/music/ui/component/IconButton.kt new file mode 100644 index 000000000..87e657bbc --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/IconButton.kt @@ -0,0 +1,41 @@ +package com.zionhuang.music.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.Indication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource + +@Composable +fun ResizableIconButton( + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurface, + enabled: Boolean = true, + indication: Indication? = null, + onClick: () -> Unit = {}, +) { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = Modifier + .clickable( + indication = indication ?: rememberRipple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + enabled = enabled, + onClick = onClick + ) + .alpha(if (enabled) 1f else 0.5f) + .then(modifier) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/component/Items.kt b/app/src/main/java/com/zionhuang/music/ui/component/Items.kt new file mode 100644 index 000000000..c2a46449c --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/Items.kt @@ -0,0 +1,761 @@ +package com.zionhuang.music.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandIn +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED +import androidx.media3.exoplayer.offline.Download.STATE_DOWNLOADING +import androidx.media3.exoplayer.offline.Download.STATE_QUEUED +import coil.compose.AsyncImage +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.models.ArtistItem +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.YTItem +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.R +import com.zionhuang.music.constants.GridThumbnailHeight +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.constants.ListThumbnailSize +import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.db.entities.Album +import com.zionhuang.music.db.entities.Artist +import com.zionhuang.music.db.entities.Playlist +import com.zionhuang.music.db.entities.Song +import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.utils.joinByBullet +import com.zionhuang.music.utils.makeTimeString + +@Composable +inline fun ListItem( + modifier: Modifier = Modifier, + title: String, + noinline subtitle: (@Composable RowScope.() -> Unit)? = null, + thumbnailContent: @Composable () -> Unit, + trailingContent: @Composable RowScope.() -> Unit = {}, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .height(ListItemHeight) + .padding(horizontal = 6.dp), + ) { + Box( + modifier = Modifier.padding(6.dp), + contentAlignment = Alignment.Center + ) { + thumbnailContent() + } + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 6.dp) + ) { + Text( + text = title, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (subtitle != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + subtitle() + } + } + } + + trailingContent() + } +} + +@Composable +fun ListItem( + modifier: Modifier = Modifier, + title: String, + subtitle: String?, + badges: @Composable RowScope.() -> Unit = {}, + thumbnailContent: @Composable () -> Unit, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = title, + subtitle = { + badges() + + if (!subtitle.isNullOrEmpty()) { + Text( + text = subtitle, + color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + thumbnailContent = thumbnailContent, + trailingContent = trailingContent, + modifier = modifier +) + +@Composable +fun GridItem( + modifier: Modifier = Modifier, + title: String, + subtitle: (@Composable RowScope.() -> Unit)? = null, + thumbnailContent: @Composable () -> Unit, +) { + Column( + modifier = modifier.padding(12.dp) + ) { + Box { + thumbnailContent() + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + if (subtitle != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + subtitle() + } + } + } +} + +@Composable +fun SongListItem( + song: Song, + modifier: Modifier = Modifier, + albumIndex: Int? = null, + showLikedIcon: Boolean = true, + showInLibraryIcon: Boolean = false, + showDownloadIcon: Boolean = true, + badges: @Composable RowScope.() -> Unit = { + if (showLikedIcon && song.song.liked) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (showInLibraryIcon && song.song.inLibrary != null) { + Icon( + painter = painterResource(R.drawable.library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (showDownloadIcon) { + val download by LocalDownloadUtil.current.getDownload(song.id).collectAsState(initial = null) + when (download?.state) { + STATE_COMPLETED -> Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + + STATE_QUEUED, STATE_DOWNLOADING -> CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .size(16.dp) + .padding(end = 2.dp) + ) + + else -> {} + } + } + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = song.song.title, + subtitle = joinByBullet( + song.artists.joinToString(), + makeTimeString(song.song.duration * 1000L) + ), + badges = badges, + thumbnailContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(ListThumbnailSize) + ) { + if (albumIndex != null) { + AnimatedVisibility( + visible = !isActive, + enter = fadeIn() + expandIn(expandFrom = Alignment.Center), + exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() + ) { + Text( + text = albumIndex.toString(), + style = MaterialTheme.typography.labelLarge + ) + } + } else { + AsyncImage( + model = song.song.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } + + PlayingIndicatorBox( + isActive = isActive, + playWhenReady = isPlaying, + color = if (albumIndex != null) MaterialTheme.colorScheme.onBackground else Color.White, + modifier = Modifier + .fillMaxSize() + .background( + color = if (albumIndex != null) Color.Transparent else Color.Black.copy(alpha = 0.4f), + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) + } + }, + trailingContent = trailingContent, + modifier = modifier +) + +@Composable +fun ArtistListItem( + artist: Artist, + modifier: Modifier = Modifier, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = artist.artist.name, + subtitle = pluralStringResource(R.plurals.n_song, artist.songCount, artist.songCount), + thumbnailContent = { + AsyncImage( + model = artist.artist.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(ListThumbnailSize) + .clip(CircleShape) + ) + }, + trailingContent = trailingContent, + modifier = modifier +) + +@Composable +fun AlbumListItem( + album: Album, + modifier: Modifier = Modifier, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = album.album.title, + subtitle = joinByBullet( + album.artists.joinToString(), + pluralStringResource(R.plurals.n_song, album.album.songCount, album.album.songCount), + album.album.year?.toString() + ), + badges = { + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + var songs by remember { + mutableStateOf(emptyList()) + } + + LaunchedEffect(Unit) { + database.albumSongs(album.id).collect { + songs = it + } + } + + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.id]?.state == STATE_COMPLETED }) + STATE_COMPLETED + else if (songs.all { + downloads[it.id]?.state == STATE_QUEUED + || downloads[it.id]?.state == STATE_DOWNLOADING + || downloads[it.id]?.state == STATE_COMPLETED + }) + STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + + when (downloadState) { + STATE_COMPLETED -> Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + + STATE_DOWNLOADING -> CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .size(16.dp) + .padding(end = 2.dp) + ) + + else -> {} + } + }, + thumbnailContent = { + AsyncImage( + model = album.album.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + + PlayingIndicatorBox( + isActive = isActive, + playWhenReady = isPlaying, + modifier = Modifier + .size(ListThumbnailSize) + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) + }, + trailingContent = trailingContent, + modifier = modifier +) + +@Composable +fun PlaylistListItem( + playlist: Playlist, + modifier: Modifier = Modifier, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = playlist.playlist.name, + subtitle = pluralStringResource(R.plurals.n_song, playlist.songCount, playlist.songCount), + thumbnailContent = { + when (playlist.thumbnails.size) { + 0 -> Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + modifier = Modifier.size(ListThumbnailSize) + ) + + 1 -> AsyncImage( + model = playlist.thumbnails[0], + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + + else -> Box( + modifier = Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd + ).fastForEachIndexed { index, alignment -> + AsyncImage( + model = playlist.thumbnails.getOrNull(index), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(ListThumbnailSize / 2) + ) + } + } + } + }, + trailingContent = trailingContent, + modifier = modifier +) + +@Composable +fun MediaMetadataListItem( + mediaMetadata: MediaMetadata, + modifier: Modifier, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = mediaMetadata.title, + subtitle = joinByBullet( + mediaMetadata.artists.joinToString { it.name }, + makeTimeString(mediaMetadata.duration * 1000L) + ), + thumbnailContent = { + AsyncImage( + model = mediaMetadata.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + + PlayingIndicatorBox( + isActive = isActive, + playWhenReady = isPlaying, + modifier = Modifier + .size(ListThumbnailSize) + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) + }, + trailingContent = trailingContent, + modifier = modifier +) + +@Composable +fun YouTubeListItem( + item: YTItem, + modifier: Modifier = Modifier, + albumIndex: Int? = null, + badges: @Composable RowScope.() -> Unit = { + val database = LocalDatabase.current + val song by database.song(item.id).collectAsState(initial = null) + val album by database.album(item.id).collectAsState(initial = null) + val playlist by database.playlist(item.id).collectAsState(initial = null) + + if (item is SongItem && song?.song?.liked == true) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem && song?.song?.inLibrary != null || + item is AlbumItem && album != null || + item is PlaylistItem && playlist != null + ) { + Icon( + painter = painterResource(R.drawable.library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem) { + val downloads by LocalDownloadUtil.current.downloads.collectAsState() + when (downloads[item.id]?.state) { + STATE_COMPLETED -> Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + + STATE_QUEUED, STATE_DOWNLOADING -> CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .size(16.dp) + .padding(end = 2.dp) + ) + + else -> {} + } + } + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = item.title, + subtitle = when (item) { + is SongItem -> joinByBullet(item.artists.joinToString { it.name }, makeTimeString(item.duration?.times(1000L))) + is AlbumItem -> joinByBullet(item.artists?.joinToString { it.name }, item.year?.toString()) + is ArtistItem -> null + is PlaylistItem -> joinByBullet(item.author.name, item.songCountText) + }, + badges = badges, + thumbnailContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(ListThumbnailSize) + ) { + val thumbnailShape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius) + if (albumIndex != null) { + AnimatedVisibility( + visible = !isActive, + enter = fadeIn() + expandIn(expandFrom = Alignment.Center), + exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() + ) { + Text( + text = albumIndex.toString(), + style = MaterialTheme.typography.labelLarge + ) + } + } else { + AsyncImage( + model = item.thumbnail, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(thumbnailShape) + ) + } + + PlayingIndicatorBox( + isActive = isActive, + playWhenReady = isPlaying, + color = if (albumIndex != null) MaterialTheme.colorScheme.onBackground else Color.White, + modifier = Modifier + .fillMaxSize() + .background( + color = if (albumIndex != null) Color.Transparent else Color.Black.copy(alpha = 0.4f), + shape = thumbnailShape + ) + ) + } + }, + trailingContent = trailingContent, + modifier = modifier +) + +@Composable +fun YouTubeGridItem( + item: YTItem, + modifier: Modifier = Modifier, + badges: @Composable RowScope.() -> Unit = { + val database = LocalDatabase.current + val song by database.song(item.id).collectAsState(initial = null) + val album by database.album(item.id).collectAsState(initial = null) + val playlist by database.playlist(item.id).collectAsState(initial = null) + + if (item is SongItem && song?.song?.liked == true) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem && song?.song?.inLibrary != null || + item is AlbumItem && album != null || + item is PlaylistItem && playlist != null + ) { + Icon( + painter = painterResource(R.drawable.library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem) { + val downloads by LocalDownloadUtil.current.downloads.collectAsState() + when (downloads[item.id]?.state) { + STATE_COMPLETED -> Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + + STATE_DOWNLOADING -> CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .size(16.dp) + .padding(end = 2.dp) + ) + + else -> {} + } + } + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + fillMaxWidth: Boolean = false, +) { + val thumbnailShape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius) + val thumbnailRatio = if (item is SongItem) 16f / 9 else 1f + + Column( + modifier = if (fillMaxWidth) { + modifier + .padding(12.dp) + .fillMaxWidth() + } else { + modifier + .padding(12.dp) + .width(GridThumbnailHeight * thumbnailRatio) + } + ) { + Box( + modifier = if (fillMaxWidth) { + Modifier.fillMaxWidth() + } else { + Modifier.height(GridThumbnailHeight) + } + .aspectRatio(thumbnailRatio) + .clip(thumbnailShape) + ) { + AsyncImage( + model = item.thumbnail, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + + androidx.compose.animation.AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = thumbnailShape + ) + ) { + if (isPlaying) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp) + ) + } else { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White + ) + } + } + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = if (item is ArtistItem) TextAlign.Center else TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + badges() + + val subtitle = when (item) { + is SongItem -> joinByBullet(item.artists.joinToString { it.name }, makeTimeString(item.duration?.times(1000L))) + is AlbumItem -> joinByBullet(item.artists?.joinToString { it.name }, item.year?.toString()) + is ArtistItem -> null + is PlaylistItem -> joinByBullet(item.author.name, item.songCountText) + } + + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/Lyrics.kt b/app/src/main/java/com/zionhuang/music/ui/component/Lyrics.kt new file mode 100644 index 000000000..d315665ef --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/Lyrics.kt @@ -0,0 +1,506 @@ +package com.zionhuang.music.ui.component + +import android.app.SearchManager +import android.content.Intent +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.LyricsTextPositionKey +import com.zionhuang.music.db.entities.LyricsEntity +import com.zionhuang.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND +import com.zionhuang.music.lyrics.LyricsEntry +import com.zionhuang.music.lyrics.LyricsEntry.Companion.HEAD_LYRICS_ENTRY +import com.zionhuang.music.lyrics.LyricsUtils.findCurrentLineIndex +import com.zionhuang.music.lyrics.LyricsUtils.parseLyrics +import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.ui.component.shimmer.ShimmerHost +import com.zionhuang.music.ui.component.shimmer.TextPlaceholder +import com.zionhuang.music.ui.screens.settings.LyricsPosition +import com.zionhuang.music.ui.utils.fadingEdge +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.viewmodels.LyricsMenuViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlin.time.Duration.Companion.seconds + +@Composable +fun Lyrics( + sliderPositionProvider: () -> Long?, + modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val menuState = LocalMenuState.current + val density = LocalDensity.current + + val lyricsTextPosition by rememberEnumPreference(LyricsTextPositionKey, LyricsPosition.CENTER) + + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val lyricsEntity by playerConnection.currentLyrics.collectAsState(initial = null) + val lyrics = remember(lyricsEntity) { + lyricsEntity?.lyrics + } + + val lines = remember(lyrics) { + if (lyrics == null || lyrics == LYRICS_NOT_FOUND) emptyList() + else if (lyrics.startsWith("[")) listOf(HEAD_LYRICS_ENTRY) + parseLyrics(lyrics) + else lyrics.lines().mapIndexed { index, line -> LyricsEntry(index * 100L, line) } + } + val isSynced = remember(lyrics) { + !lyrics.isNullOrEmpty() && lyrics.startsWith("[") + } + + var currentLineIndex by remember { + mutableStateOf(-1) + } + // Because LaunchedEffect has delay, which leads to inconsistent with current line color and scroll animation, + // we use deferredCurrentLineIndex when user is scrolling + var deferredCurrentLineIndex by rememberSaveable { + mutableStateOf(0) + } + + var lastPreviewTime by rememberSaveable { + mutableStateOf(0L) + } + var isSeeking by remember { + mutableStateOf(false) + } + + LaunchedEffect(lyrics) { + if (lyrics.isNullOrEmpty() || !lyrics.startsWith("[")) { + currentLineIndex = -1 + return@LaunchedEffect + } + while (isActive) { + delay(50) + val sliderPosition = sliderPositionProvider() + isSeeking = sliderPosition != null + currentLineIndex = findCurrentLineIndex(lines, sliderPosition ?: playerConnection.player.currentPosition) + } + } + + LaunchedEffect(isSeeking, lastPreviewTime) { + if (isSeeking) { + lastPreviewTime = 0L + } else if (lastPreviewTime != 0L) { + delay(LyricsPreviewTime) + lastPreviewTime = 0L + } + } + + val lazyListState = rememberLazyListState() + + LaunchedEffect(currentLineIndex, lastPreviewTime) { + if (!isSynced) return@LaunchedEffect + if (currentLineIndex != -1) { + deferredCurrentLineIndex = currentLineIndex + if (lastPreviewTime == 0L) { + if (isSeeking) { + lazyListState.scrollToItem(currentLineIndex, with(density) { 36.dp.toPx().toInt() }) + } else { + lazyListState.animateScrollToItem(currentLineIndex, with(density) { 36.dp.toPx().toInt() }) + } + } + } + } + + BoxWithConstraints( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxSize() + .padding(bottom = 12.dp) + ) { + LazyColumn( + state = lazyListState, + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Top) + .add(WindowInsets(top = maxHeight / 2, bottom = maxHeight / 2)) + .asPaddingValues(), + modifier = Modifier + .fadingEdge(vertical = 64.dp) + .nestedScroll(remember { + object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + lastPreviewTime = System.currentTimeMillis() + return super.onPostScroll(consumed, available, source) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + lastPreviewTime = System.currentTimeMillis() + return super.onPostFling(consumed, available) + } + } + }) + ) { + val displayedCurrentLineIndex = if (isSeeking) deferredCurrentLineIndex else currentLineIndex + itemsIndexed( + items = lines + ) { index, item -> + Text( + text = item.text, + fontSize = 20.sp, + color = if (index == displayedCurrentLineIndex) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, + textAlign = when (lyricsTextPosition) { + LyricsPosition.LEFT -> TextAlign.Left + LyricsPosition.CENTER -> TextAlign.Center + LyricsPosition.RIGHT -> TextAlign.Right + }, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = isSynced) { + playerConnection.player.seekTo(item.time) + lastPreviewTime = 0L + } + .padding(horizontal = 24.dp, vertical = 8.dp) + .alpha(if (!isSynced || index == displayedCurrentLineIndex) 1f else 0.5f) + ) + } + + if (lyrics == null) { + item { + ShimmerHost { + repeat(10) { + Box( + contentAlignment = when (lyricsTextPosition) { + LyricsPosition.LEFT -> Alignment.CenterStart + LyricsPosition.CENTER -> Alignment.Center + LyricsPosition.RIGHT -> Alignment.CenterEnd + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp) + ) { + TextPlaceholder() + } + } + } + } + } + } + + if (lyrics == LYRICS_NOT_FOUND) { + Text( + text = stringResource(R.string.lyrics_not_found), + fontSize = 20.sp, + color = MaterialTheme.colorScheme.secondary, + textAlign = when (lyricsTextPosition) { + LyricsPosition.LEFT -> TextAlign.Left + LyricsPosition.CENTER -> TextAlign.Center + LyricsPosition.RIGHT -> TextAlign.Right + }, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + .alpha(0.5f) + ) + } + + mediaMetadata?.let { mediaMetadata -> + IconButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(12.dp), + onClick = { + menuState.show { + LyricsMenu( + lyricsProvider = { lyricsEntity }, + mediaMetadataProvider = { mediaMetadata }, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(id = R.drawable.more_horiz), + contentDescription = null + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LyricsMenu( + lyricsProvider: () -> LyricsEntity?, + mediaMetadataProvider: () -> MediaMetadata, + onDismiss: () -> Unit, + viewModel: LyricsMenuViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val database = LocalDatabase.current + + var showEditDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showEditDialog) { + TextFieldDialog( + onDismiss = { showEditDialog = false }, + icon = { Icon(painter = painterResource(R.drawable.edit), contentDescription = null) }, + title = { Text(text = mediaMetadataProvider().title) }, + initialTextFieldValue = TextFieldValue(lyricsProvider()?.lyrics.orEmpty()), + singleLine = false, + onDone = { + database.query { + upsert( + LyricsEntity( + id = mediaMetadataProvider().id, + lyrics = it + ) + ) + } + } + ) + } + + var showSearchDialog by rememberSaveable { + mutableStateOf(false) + } + var showSearchResultDialog by rememberSaveable { + mutableStateOf(false) + } + + val searchMediaMetadata = remember(showSearchDialog) { + mediaMetadataProvider() + } + val (titleField, onTitleFieldChange) = rememberSaveable(showSearchDialog, stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = mediaMetadataProvider().title + ) + ) + } + val (artistField, onArtistFieldChange) = rememberSaveable(showSearchDialog, stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = mediaMetadataProvider().artists.joinToString { it.name } + ) + ) + } + + if (showSearchDialog) { + DefaultDialog( + modifier = Modifier.verticalScroll(rememberScrollState()), + onDismiss = { showSearchDialog = false }, + icon = { Icon(painter = painterResource(R.drawable.search), contentDescription = null) }, + title = { Text(stringResource(R.string.search_lyrics)) }, + buttons = { + TextButton( + onClick = { showSearchDialog = false } + ) { + Text(stringResource(android.R.string.cancel)) + } + + Spacer(Modifier.width(8.dp)) + + TextButton( + onClick = { + showSearchDialog = false + onDismiss() + try { + context.startActivity( + Intent(Intent.ACTION_WEB_SEARCH).apply { + putExtra(SearchManager.QUERY, "${artistField.text} ${titleField.text} lyrics") + } + ) + } catch (_: Exception) { + } + } + ) { + Text(stringResource(R.string.search_online)) + } + + Spacer(Modifier.width(8.dp)) + + TextButton( + onClick = { + viewModel.search(searchMediaMetadata.id, titleField.text, artistField.text, searchMediaMetadata.duration) + showSearchResultDialog = true + } + ) { + Text(stringResource(android.R.string.ok)) + } + } + ) { + OutlinedTextField( + value = titleField, + onValueChange = onTitleFieldChange, + singleLine = true, + label = { Text(stringResource(R.string.song_title)) } + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = artistField, + onValueChange = onArtistFieldChange, + singleLine = true, + label = { Text(stringResource(R.string.song_artists)) } + ) + } + } + + if (showSearchResultDialog) { + val results by viewModel.results.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + var expandedItemIndex by rememberSaveable { + mutableStateOf(-1) + } + + ListDialog( + onDismiss = { showSearchResultDialog = false } + ) { + itemsIndexed(results) { index, result -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onDismiss() + viewModel.cancelSearch() + database.query { + upsert( + LyricsEntity( + id = searchMediaMetadata.id, + lyrics = result.lyrics + ) + ) + } + } + .padding(12.dp) + .animateContentSize() + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = result.lyrics, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (index == expandedItemIndex) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = result.providerName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1 + ) + if (result.lyrics.startsWith("[")) { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .padding(start = 4.dp) + .size(18.dp) + ) + } + } + } + + IconButton( + onClick = { + expandedItemIndex = if (expandedItemIndex == index) -1 else index + } + ) { + Icon( + painter = painterResource(if (index == expandedItemIndex) R.drawable.expand_less else R.drawable.expand_more), + contentDescription = null + ) + } + } + } + + if (isLoading) { + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + CircularProgressIndicator() + } + } + } + + if (!isLoading && results.isEmpty()) { + item { + Text( + text = context.getString(R.string.lyrics_not_found), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + ) + } + } + } + } + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + GridMenuItem( + icon = R.drawable.edit, + title = R.string.edit + ) { + showEditDialog = true + } + GridMenuItem( + icon = R.drawable.cached, + title = R.string.refetch + ) { + onDismiss() + viewModel.refetchLyrics(mediaMetadataProvider(), lyricsProvider()) + } + GridMenuItem( + icon = R.drawable.search, + title = R.string.search, + ) { + showSearchDialog = true + } + } +} + +const val animateScrollDuration = 300L +val LyricsPreviewTime = 4.seconds diff --git a/app/src/main/java/com/zionhuang/music/ui/component/PlayingIndicator.kt b/app/src/main/java/com/zionhuang/music/ui/component/PlayingIndicator.kt new file mode 100644 index 000000000..0560f5717 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/PlayingIndicator.kt @@ -0,0 +1,112 @@ +package com.zionhuang.music.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.zionhuang.music.R +import com.zionhuang.music.constants.ThumbnailCornerRadius +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.random.Random + + +@Composable +fun PlayingIndicator( + color: Color, + modifier: Modifier = Modifier, + bars: Int = 3, + barWidth: Dp = 4.dp, + cornerRadius: Dp = ThumbnailCornerRadius, +) { + val animatables = remember { + List(bars) { + Animatable(0.1f) + } + } + + LaunchedEffect(Unit) { + delay(300) + animatables.forEach { animatable -> + launch { + while (true) { + animatable.animateTo(Random.nextFloat() * 0.9f + 0.1f) + delay(50) + } + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Bottom, + modifier = modifier + ) { + animatables.forEach { animatable -> + Canvas( + modifier = Modifier + .fillMaxHeight() + .width(barWidth) + ) { + drawRoundRect( + color = color, + topLeft = Offset(x = 0f, y = size.height * (1 - animatable.value)), + size = size.copy(height = animatable.value * size.height), + cornerRadius = CornerRadius(cornerRadius.toPx()) + ) + } + } + } +} + +@Composable +fun PlayingIndicatorBox( + modifier: Modifier = Modifier, + isActive: Boolean, + playWhenReady: Boolean, + color: Color = Color.White, +) { + AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + ) { + if (playWhenReady) { + PlayingIndicator( + color = color, + modifier = Modifier.height(24.dp) + ) + } else { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = color + ) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/Preference.kt b/app/src/main/java/com/zionhuang/music/ui/component/Preference.kt new file mode 100644 index 000000000..e0f0f8d4a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/Preference.kt @@ -0,0 +1,231 @@ +package com.zionhuang.music.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp + +@Composable +fun PreferenceEntry( + modifier: Modifier = Modifier, + title: String, + description: String? = null, + content: (@Composable () -> Unit)? = null, + @DrawableRes icon: Int? = null, + trailingContent: (@Composable () -> Unit)? = null, + onClick: () -> Unit, + isEnabled: Boolean = true, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clickable( + enabled = isEnabled, + onClick = onClick + ) + .alpha(if (isEnabled) 1f else 0.5f) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + if (icon != null) { + Box( + modifier = Modifier.padding(horizontal = 4.dp) + ) { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + } + + Spacer(Modifier.width(12.dp)) + } + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + + content?.invoke() + } + + if (trailingContent != null) { + Spacer(Modifier.width(12.dp)) + + trailingContent() + } + } +} + +@Composable +fun ListPreference( + modifier: Modifier = Modifier, + title: String, + @DrawableRes icon: Int? = null, + selectedValue: T, + values: List, + valueText: @Composable (T) -> String, + onValueSelected: (T) -> Unit, + isEnabled: Boolean = true, +) { + var showDialog by remember { + mutableStateOf(false) + } + if (showDialog) { + ListDialog( + onDismiss = { showDialog = false } + ) { + items(values) { value -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + showDialog = false + onValueSelected(value) + } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + RadioButton( + selected = value == selectedValue, + onClick = null + ) + + Text( + text = valueText(value), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + } + + PreferenceEntry( + modifier = modifier, + title = title, + description = valueText(selectedValue), + icon = icon, + onClick = { showDialog = true }, + isEnabled = isEnabled + ) +} + +@Composable +inline fun > EnumListPreference( + modifier: Modifier = Modifier, + title: String, + @DrawableRes icon: Int, + selectedValue: T, + noinline valueText: @Composable (T) -> String, + noinline onValueSelected: (T) -> Unit, + isEnabled: Boolean = true, +) { + ListPreference( + modifier = modifier, + title = title, + icon = icon, + selectedValue = selectedValue, + values = enumValues().toList(), + valueText = valueText, + onValueSelected = onValueSelected, + isEnabled = isEnabled + ) +} + +@Composable +fun SwitchPreference( + modifier: Modifier = Modifier, + title: String, + description: String? = null, + @DrawableRes icon: Int? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean = true, +) { + PreferenceEntry( + modifier = modifier, + title = title, + description = description, + icon = icon, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + }, + onClick = { onCheckedChange(!checked) }, + isEnabled = isEnabled + ) +} + +@Composable +fun EditTextPreference( + modifier: Modifier = Modifier, + title: String, + @DrawableRes icon: Int? = null, + value: String, + onValueChange: (String) -> Unit, + singleLine: Boolean = true, + isInputValid: (String) -> Boolean = { it.isNotEmpty() }, + isEnabled: Boolean = true, +) { + var showDialog by remember { + mutableStateOf(false) + } + + if (showDialog) { + TextFieldDialog( + initialTextFieldValue = TextFieldValue( + text = value, + selection = TextRange(value.length) + ), + singleLine = singleLine, + isInputValid = isInputValid, + onDone = onValueChange, + onDismiss = { showDialog = false } + ) + } + + PreferenceEntry( + modifier = modifier, + title = title, + description = value, + icon = icon, + onClick = { showDialog = true }, + isEnabled = isEnabled + ) +} + +@Composable +fun PreferenceGroupTitle( + title: String, + modifier: Modifier = Modifier, +) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = modifier.padding(16.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt b/app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt new file mode 100644 index 000000000..e6db353d1 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt @@ -0,0 +1,301 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.zionhuang.music.ui.component + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.material3.tokens.MotionTokens +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import androidx.compose.ui.util.lerp +import com.zionhuang.music.constants.AppBarHeight +import kotlin.math.max +import kotlin.math.roundToInt + +@ExperimentalMaterial3Api +@Composable +fun SearchBar( + query: TextFieldValue, + onQueryChange: (TextFieldValue) -> Unit, + onSearch: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + scrollBehavior: TopAppBarScrollBehavior, + modifier: Modifier = Modifier, + enabled: Boolean = true, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + shape: Shape = SearchBarDefaults.inputFieldShape, + colors: SearchBarColors = SearchBarDefaults.colors(), + tonalElevation: Dp = SearchBarDefaults.Elevation, + windowInsets: WindowInsets = WindowInsets.systemBars, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable ColumnScope.() -> Unit, +) { + val heightOffsetLimit = with(LocalDensity.current) { + -(AppBarHeight.toPx() + WindowInsets.systemBars.getTop(this)) + } + SideEffect { + if (scrollBehavior.state.heightOffsetLimit != heightOffsetLimit) { + scrollBehavior.state.heightOffsetLimit = heightOffsetLimit + } + } + + val animationProgress: Float by animateFloatAsState( + targetValue = if (active) 1f else 0f, + animationSpec = tween( + durationMillis = AnimationDurationMillis, + easing = MotionTokens.EasingLegacyCubicBezier, + ) + ) + + val defaultInputFieldShape = SearchBarDefaults.inputFieldShape + val defaultFullScreenShape = SearchBarDefaults.fullScreenShape + val animatedShape by remember { + derivedStateOf { + when { + shape == defaultInputFieldShape -> { + // The shape can only be animated if it's the default spec value + val animatedRadius = SearchBarCornerRadius * (1 - animationProgress) + RoundedCornerShape(CornerSize(animatedRadius)) + } + animationProgress == 1f -> defaultFullScreenShape + else -> shape + } + } + } + + val topInset = windowInsets.asPaddingValues().calculateTopPadding() + val startInset = windowInsets.asPaddingValues().calculateStartPadding(LocalLayoutDirection.current) + val endInset = windowInsets.asPaddingValues().calculateEndPadding(LocalLayoutDirection.current) + + val topPadding = SearchBarVerticalPadding + topInset + val animatedSurfaceTopPadding = lerp(topPadding, 0.dp, animationProgress) + val animatedInputFieldPadding by remember { + derivedStateOf { + PaddingValues( + start = startInset * animationProgress, + top = topPadding * animationProgress, + end = endInset * animationProgress, + bottom = SearchBarVerticalPadding * animationProgress, + ) + } + } + + BoxWithConstraints( + modifier = modifier + .offset { + IntOffset(x = 0, y = scrollBehavior.state.heightOffset.roundToInt()) + }, + propagateMinConstraints = true + ) { + val height: Dp + val width: Dp + val startPadding: Dp + val endPadding: Dp + with(LocalDensity.current) { + val startWidth = constraints.maxWidth.toFloat() + val startHeight = max(constraints.minHeight, InputFieldHeight.roundToPx()) + .coerceAtMost(constraints.maxHeight) + .toFloat() + val endWidth = constraints.maxWidth.toFloat() + val endHeight = constraints.maxHeight.toFloat() + + height = lerp(startHeight, endHeight, animationProgress).toDp() + width = lerp(startWidth, endWidth, animationProgress).toDp() + startPadding = lerp((SearchBarHorizontalPadding + startInset).roundToPx().toFloat(), 0f, animationProgress).toDp() + endPadding = lerp((SearchBarHorizontalPadding + endInset).roundToPx().toFloat(), 0f, animationProgress).toDp() + } + + Surface( + shape = animatedShape, + color = colors.containerColor, + contentColor = contentColorFor(colors.containerColor), + tonalElevation = tonalElevation, + modifier = Modifier + .padding( + top = animatedSurfaceTopPadding, + start = startPadding, + end = endPadding + ) + .size(width = width, height = height) + ) { + Column { + SearchBarInputField( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + active = active, + onActiveChange = onActiveChange, + modifier = Modifier.padding(animatedInputFieldPadding), + enabled = enabled, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + colors = colors.inputFieldColors, + interactionSource = interactionSource, + ) + + if (animationProgress > 0) { + Column(Modifier.alpha(animationProgress)) { + Divider(color = colors.dividerColor) + content() + } + } + } + } + } + + BackHandler(enabled = active) { + onActiveChange(false) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun SearchBarInputField( + query: TextFieldValue, + onQueryChange: (TextFieldValue) -> Unit, + onSearch: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + colors: TextFieldColors = SearchBarDefaults.inputFieldColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focusRequester = remember { FocusRequester() } + val searchSemantics = getString(Strings.SearchBarSearch) + val suggestionsAvailableSemantics = getString(Strings.SuggestionsAvailable) + val textColor = LocalTextStyle.current.color.takeOrElse { + colors.textColor(enabled).value + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .height(InputFieldHeight) + ) { + if (leadingIcon != null) { + Spacer(Modifier.width(SearchBarIconOffsetX)) + leadingIcon() + } + + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .pointerInput(Unit) { + awaitEachGesture { + // Must be PointerEventPass.Initial to observe events before the text field + // consumes them in the Main pass + awaitFirstDown(pass = PointerEventPass.Initial) + val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) + if (upEvent != null) { + onActiveChange(true) + } + } + } + .semantics { + contentDescription = searchSemantics + if (active) { + stateDescription = suggestionsAvailableSemantics + } + } + .onKeyEvent { + if (it.key == Key.Enter) { + onSearch(query.text) + return@onKeyEvent true + } + false + }, + enabled = enabled, + singleLine = true, + textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)), + cursorBrush = SolidColor(colors.cursorColor(isError = false).value), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch(query.text) }), + interactionSource = interactionSource, + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.CenterStart + ) { + if (placeholder != null && query.text.isEmpty()) { + Box(Modifier.alpha(0.8f)) { + Decoration( + contentColor = colors.placeholderColor(enabled).value, + typography = MaterialTheme.typography.bodyLarge, + content = placeholder + ) + } + } + innerTextField() + } + } + ) + + if (trailingIcon != null) { + trailingIcon() + Spacer(Modifier.width(SearchBarIconOffsetX)) + } + } +} + +// Measurement specs +val InputFieldHeight = 48.dp +private val SearchBarCornerRadius: Dp = InputFieldHeight / 2 +internal val SearchBarMinWidth: Dp = 360.dp +private val SearchBarMaxWidth: Dp = 720.dp +internal val SearchBarVerticalPadding: Dp = 8.dp +internal val SearchBarHorizontalPadding: Dp = 12.dp + +// Search bar has 16dp padding between icons and start/end, while by default text field has 12dp. +val SearchBarIconOffsetX: Dp = 4.dp + +// Animation specs +private const val AnimationDurationMillis: Int = MotionTokens.DurationMedium2.toInt() diff --git a/app/src/main/java/com/zionhuang/music/ui/component/SortHeader.kt b/app/src/main/java/com/zionhuang/music/ui/component/SortHeader.kt new file mode 100644 index 000000000..57922f5d8 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/SortHeader.kt @@ -0,0 +1,108 @@ +package com.zionhuang.music.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.zionhuang.music.R +import com.zionhuang.music.constants.PlaylistSongSortType + +@Composable +inline fun > SortHeader( + sortType: T, + sortDescending: Boolean, + crossinline onSortTypeChange: (T) -> Unit, + crossinline onSortDescendingChange: (Boolean) -> Unit, + crossinline sortTypeText: (T) -> Int, + trailingText: String, + modifier: Modifier = Modifier, +) { + var menuExpanded by remember { mutableStateOf(false) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = stringResource(sortTypeText(sortType)), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false) + ) { + menuExpanded = !menuExpanded + } + .padding(horizontal = 4.dp, vertical = 8.dp) + ) + + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + modifier = Modifier.widthIn(min = 172.dp) + ) { + enumValues().forEach { type -> + DropdownMenuItem( + text = { + Text( + text = stringResource(sortTypeText(type)), + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + }, + trailingIcon = { + Icon( + painter = painterResource(if (sortType == type) R.drawable.radio_button_checked else R.drawable.radio_button_unchecked), + contentDescription = null + ) + }, + onClick = { + onSortTypeChange(type) + menuExpanded = false + } + ) + } + } + + if (sortType != PlaylistSongSortType.CUSTOM) { + ResizableIconButton( + icon = if (sortDescending) R.drawable.arrow_downward else R.drawable.arrow_upward, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(32.dp) + .padding(8.dp), + onClick = { onSortDescendingChange(!sortDescending) } + ) + } + + Spacer(Modifier.weight(1f)) + + Text( + text = trailingText, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/shimmer/ButtonPlaceholder.kt b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/ButtonPlaceholder.kt new file mode 100644 index 000000000..eb24f52e3 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/ButtonPlaceholder.kt @@ -0,0 +1,21 @@ +package com.zionhuang.music.ui.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip + +@Composable +fun ButtonPlaceholder( + modifier: Modifier = Modifier, +) { + Spacer(modifier + .height(ButtonDefaults.MinHeight) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.onSurface)) +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/component/shimmer/GridItemPlaceholder.kt b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/GridItemPlaceholder.kt new file mode 100644 index 000000000..66897e7ed --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/GridItemPlaceholder.kt @@ -0,0 +1,49 @@ +package com.zionhuang.music.ui.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import com.zionhuang.music.constants.GridThumbnailHeight +import com.zionhuang.music.constants.ThumbnailCornerRadius + +@Composable +fun GridItemPlaceHolder( + modifier: Modifier = Modifier, + thumbnailShape: Shape = RoundedCornerShape(ThumbnailCornerRadius), + fillMaxWidth: Boolean = false, +) { + Column( + modifier = if (fillMaxWidth) { + modifier + .padding(12.dp) + .fillMaxWidth() + } else { + modifier + .padding(12.dp) + .width(GridThumbnailHeight) + } + ) { + Spacer( + modifier = if (fillMaxWidth) { + Modifier.fillMaxWidth() + } else { + Modifier.height(GridThumbnailHeight) + } + .aspectRatio(1f) + .clip(thumbnailShape) + .background(MaterialTheme.colorScheme.onSurface) + ) + + Spacer(modifier = Modifier.height(6.dp)) + + TextPlaceholder() + + TextPlaceholder() + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/shimmer/ListItemPlaceholder.kt b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/ListItemPlaceholder.kt new file mode 100644 index 000000000..148466888 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/ListItemPlaceholder.kt @@ -0,0 +1,45 @@ +package com.zionhuang.music.ui.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.constants.ListThumbnailSize +import com.zionhuang.music.constants.ThumbnailCornerRadius + +@Composable +fun ListItemPlaceHolder( + modifier: Modifier = Modifier, + thumbnailShape: Shape = RoundedCornerShape(ThumbnailCornerRadius), +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .height(ListItemHeight) + .padding(horizontal = 6.dp), + ) { + Spacer( + modifier = Modifier + .padding(6.dp) + .size(ListThumbnailSize) + .clip(thumbnailShape) + .background(MaterialTheme.colorScheme.onSurface) + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 6.dp) + ) { + TextPlaceholder() + TextPlaceholder() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/component/shimmer/ShimmerHost.kt b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/ShimmerHost.kt new file mode 100644 index 000000000..e2718d0e0 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/ShimmerHost.kt @@ -0,0 +1,59 @@ +package com.zionhuang.music.ui.component.shimmer + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import com.valentinilk.shimmer.defaultShimmerTheme +import com.valentinilk.shimmer.shimmer + +@Composable +fun ShimmerHost( + modifier: Modifier = Modifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + horizontalAlignment = horizontalAlignment, + verticalArrangement = verticalArrangement, + modifier = modifier + .shimmer() + .graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient(listOf(Color.Black, Color.Transparent)), + blendMode = BlendMode.DstIn + ) + }, + content = content + ) +} + +val ShimmerTheme = defaultShimmerTheme.copy( + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 800, + easing = LinearEasing, + delayMillis = 250, + ), + repeatMode = RepeatMode.Restart + ), + shaderColors = listOf( + Color.Unspecified.copy(alpha = 0.25f), + Color.Unspecified.copy(alpha = 0.50f), + Color.Unspecified.copy(alpha = 0.25f), + ), +) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/component/shimmer/TextPlaceholder.kt b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/TextPlaceholder.kt new file mode 100644 index 000000000..c76b47986 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/TextPlaceholder.kt @@ -0,0 +1,28 @@ +package com.zionhuang.music.ui.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.random.Random + +@Composable +fun TextPlaceholder( + modifier: Modifier = Modifier, + height: Dp = 16.dp, +) { + Spacer( + modifier = modifier + .padding(vertical = 4.dp) + .background(MaterialTheme.colorScheme.onSurface) + .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) + .height(height) + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/AlbumsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/AlbumsFragment.kt deleted file mode 100644 index 2228c1a2d..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/AlbumsFragment.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.core.view.MenuProvider -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.LinearLayoutManager -import com.zionhuang.innertube.models.BrowseEndpoint.Companion.albumBrowseEndpoint -import com.zionhuang.music.R -import com.zionhuang.music.db.entities.Album -import com.zionhuang.music.extensions.addFastScroller -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.ui.adapters.LocalItemAdapter -import com.zionhuang.music.ui.adapters.selection.LocalItemDetailsLookup -import com.zionhuang.music.ui.adapters.selection.LocalItemKeyProvider -import com.zionhuang.music.ui.fragments.base.RecyclerViewFragment -import com.zionhuang.music.ui.listeners.AlbumMenuListener -import com.zionhuang.music.utils.NavigationEndpointHandler -import com.zionhuang.music.utils.addActionModeObserver -import com.zionhuang.music.viewmodels.SongsViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class AlbumsFragment : RecyclerViewFragment(), MenuProvider { - private val songsViewModel by activityViewModels() - private val menuListener = AlbumMenuListener(this) - override val adapter = LocalItemAdapter().apply { - albumMenuListener = menuListener - } - private var tracker: SelectionTracker? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.recyclerView.apply { - layoutManager = LinearLayoutManager(requireContext()) - addFastScroller { useMd2Style() } - addOnClickListener { position, _ -> - (this@AlbumsFragment.adapter.currentList[position] as? Album)?.let { album -> - NavigationEndpointHandler(this@AlbumsFragment).handle(albumBrowseEndpoint(album.id)) - } - } - } - - tracker = SelectionTracker.Builder("selectionId", binding.recyclerView, LocalItemKeyProvider(adapter), LocalItemDetailsLookup(binding.recyclerView), StorageStrategy.createStringStorage()) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - adapter.tracker = this - addActionModeObserver(requireActivity(), R.menu.album_batch) { item -> - val map = adapter.currentList.associateBy { it.id } - val albums = selection.toList().map { map[it] }.filterIsInstance() - when (item.itemId) { - R.id.action_play_next -> menuListener.playNext(albums) - R.id.action_add_to_queue -> menuListener.addToQueue(albums) - R.id.action_add_to_playlist -> menuListener.addToPlaylist(albums) - R.id.action_refetch -> menuListener.refetch(albums) - R.id.action_delete -> menuListener.delete(albums) - } - true - } - } - - lifecycleScope.launch { - songsViewModel.allAlbumsFlow.collectLatest { - adapter.submitList(it) - } - } - - requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - } - - override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.search_and_settings, menu) - menu.findItem(R.id.action_search).actionView = null - } - - override fun onMenuItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_search -> findNavController().navigate(R.id.localSearchFragment) - R.id.action_settings -> findNavController().navigate(R.id.settingsActivity) - } - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/ArtistsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/ArtistsFragment.kt deleted file mode 100644 index 924dd916d..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/ArtistsFragment.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.core.view.MenuProvider -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.innertube.models.BrowseEndpoint.Companion.artistBrowseEndpoint -import com.zionhuang.music.R -import com.zionhuang.music.db.entities.Artist -import com.zionhuang.music.extensions.addFastScroller -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.ui.adapters.LocalItemAdapter -import com.zionhuang.music.ui.adapters.selection.LocalItemDetailsLookup -import com.zionhuang.music.ui.adapters.selection.LocalItemKeyProvider -import com.zionhuang.music.ui.fragments.base.RecyclerViewFragment -import com.zionhuang.music.ui.listeners.ArtistMenuListener -import com.zionhuang.music.utils.NavigationEndpointHandler -import com.zionhuang.music.utils.addActionModeObserver -import com.zionhuang.music.viewmodels.SongsViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class ArtistsFragment : RecyclerViewFragment(), MenuProvider { - private val songsViewModel by activityViewModels() - private val menuListener = ArtistMenuListener(this) - override val adapter = LocalItemAdapter().apply { - artistMenuListener = menuListener - } - private var tracker: SelectionTracker? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.recyclerView.apply { - layoutManager = LinearLayoutManager(requireContext()) - addFastScroller { useMd2Style() } - addOnClickListener { position, _ -> - (this@ArtistsFragment.adapter.currentList[position] as? Artist)?.let { artist -> - if (artist.artist.isYouTubeArtist) { - NavigationEndpointHandler(this@ArtistsFragment).handle(artistBrowseEndpoint(artist.id)) - } else { - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - findNavController().navigate(ArtistsFragmentDirections.actionArtistsToArtistSongs(artist.id)) - } - } - } - } - - tracker = SelectionTracker.Builder("selectionId", binding.recyclerView, LocalItemKeyProvider(adapter), LocalItemDetailsLookup(binding.recyclerView), StorageStrategy.createStringStorage()) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - adapter.tracker = this - addActionModeObserver(requireActivity(), R.menu.artist_batch) { item -> - val map = adapter.currentList.associateBy { it.id } - val artists = selection.toList().map { map[it] }.filterIsInstance() - when (item.itemId) { - R.id.action_play_next -> menuListener.playNext(artists) - R.id.action_add_to_queue -> menuListener.addToQueue(artists) - R.id.action_add_to_playlist -> menuListener.addToPlaylist(artists) - R.id.action_refetch -> menuListener.refetch(artists) - } - true - } - } - - lifecycleScope.launch { - songsViewModel.allArtistsFlow.collectLatest { - adapter.submitList(it) - } - } - - requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - } - - override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.search_and_settings, menu) - menu.findItem(R.id.action_search).actionView = null - } - - override fun onMenuItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_search -> findNavController().navigate(R.id.localSearchFragment) - R.id.action_settings -> findNavController().navigate(R.id.settingsActivity) - } - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/BottomControlsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/BottomControlsFragment.kt deleted file mode 100644 index b92ae2dec..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/BottomControlsFragment.kt +++ /dev/null @@ -1,213 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.app.SearchManager -import android.content.Intent -import android.os.Bundle -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE -import android.support.v4.media.session.PlaybackStateCompat.* -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior.STATE_COLLAPSED -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior.STATE_HIDDEN -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.slider.Slider -import com.zionhuang.innertube.models.BrowseEndpoint -import com.zionhuang.innertube.models.BrowseLocalArtistSongsEndpoint -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIKE -import com.zionhuang.music.databinding.BottomControlsSheetBinding -import com.zionhuang.music.databinding.DialogEditLyricsBinding -import com.zionhuang.music.db.entities.LyricsEntity -import com.zionhuang.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND -import com.zionhuang.music.extensions.show -import com.zionhuang.music.extensions.systemBarInsetsCompat -import com.zionhuang.music.playback.MediaSessionConnection -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.activities.MainActivity -import com.zionhuang.music.ui.fragments.dialogs.SearchLyricsDialog -import com.zionhuang.music.utils.NavigationEndpointHandler -import com.zionhuang.music.utils.lyrics.LyricsHelper -import com.zionhuang.music.utils.makeTimeString -import com.zionhuang.music.utils.preference.PreferenceLiveData -import com.zionhuang.music.viewmodels.PlaybackViewModel -import dev.chrisbanes.insetter.applyInsetter -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - - -class BottomControlsFragment : Fragment() { - lateinit var binding: BottomControlsSheetBinding - private val viewModel by activityViewModels() - private val mainActivity: MainActivity get() = requireActivity() as MainActivity - private var sliderIsTracking: Boolean = false - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = BottomControlsSheetBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = this - binding.viewModel = viewModel - - // Immersive layout - binding.root.applyInsetter { - type(navigationBars = true) { - padding() - } - } - binding.cardViewContainer.applyInsetter { - type(statusBars = true) { - padding() - } - } - binding.lyricsView.setOnApplyWindowInsetsListener { _, insets -> - binding.lyricsView.immersivePaddingTop = insets.systemBarInsetsCompat.top - insets - } - binding.rightPart?.applyInsetter { - type(statusBars = true, navigationBars = true) { - padding() - } - } - // Marquee - binding.songTitle.isSelected = true - binding.songArtist.isSelected = true - binding.songArtist.setOnClickListener { - val mediaMetadata = MediaSessionConnection.binder?.songPlayer?.currentMediaMetadata?.value ?: return@setOnClickListener - if (mediaMetadata.artists.isNotEmpty()) { - val artist = mediaMetadata.artists[0] - NavigationEndpointHandler(mainActivity.currentFragment!!).handle(if (artist.id.startsWith("UC")) { - BrowseEndpoint.artistBrowseEndpoint(artist.id) - } else { - BrowseLocalArtistSongsEndpoint(artist.id) - }) - mainActivity.collapseBottomSheet() - } - } - - binding.btnFavorite.setOnClickListener { - viewModel.transportControls?.sendCustomAction(ACTION_TOGGLE_LIKE, null) - } - - binding.slider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { - override fun onStartTrackingTouch(slider: Slider) { - sliderIsTracking = true - } - - override fun onStopTrackingTouch(slider: Slider) { - MediaSessionConnection.mediaController?.transportControls?.seekTo(slider.value.toLong()) - sliderIsTracking = false - } - }) - binding.slider.addOnChangeListener { _, value, _ -> - binding.position.text = makeTimeString((value).toLong()) - if (sliderIsTracking) { - binding.lyricsView.updateTime(value.toLong(), animate = false) - } - } - binding.lyricsView.setDraggable(true) { time -> - viewModel.mediaController?.transportControls?.seekTo(time) - true - } - PreferenceLiveData(requireContext(), R.string.pref_lyrics_text_position, "1").observe(viewLifecycleOwner) { - binding.lyricsView.setTextGravity(it.toIntOrNull() ?: 1) - } - binding.btnLyricsAction.setOnClickListener { - MenuBottomSheetDialogFragment - .newInstance(R.menu.lyrics) - .setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.action_refetch -> lifecycleScope.launch { - MediaSessionConnection.binder?.songPlayer?.currentMediaMetadata?.value?.let { mediaMetadata -> - LyricsHelper.loadLyrics(requireContext(), mediaMetadata) - } - } - - R.id.action_edit -> { - val mediaId = viewModel.currentLyrics.value?.id ?: return@setOnMenuItemClickListener - val editLyricsBinding = DialogEditLyricsBinding.inflate(layoutInflater).apply { - textField.editText?.setText(viewModel.currentLyrics.value?.lyrics) - } - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.dialog_title_edit_lyrics) - .setView(editLyricsBinding.root) - .setPositiveButton(R.string.dialog_button_save) { _, _ -> - lifecycleScope.launch { - SongRepository(requireContext()).upsert(LyricsEntity( - mediaId, editLyricsBinding.textField.editText?.text.toString() - )) - } - } - .show() - } - R.id.action_search -> { - val mediaMetadata = viewModel.mediaMetadata.value ?: return@setOnMenuItemClickListener - try { - startActivity(Intent(Intent.ACTION_WEB_SEARCH).apply { - putExtra(SearchManager.QUERY, "${mediaMetadata.getString(METADATA_KEY_DISPLAY_SUBTITLE)} ${mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE)} lyrics") - }) - } catch (_: Exception) { - } - } - R.id.action_choose -> { - val mediaMetadata = MediaSessionConnection.binder?.songPlayer?.currentMediaMetadata?.value ?: return@setOnMenuItemClickListener - SearchLyricsDialog(mediaMetadata).show(requireContext()) - } - } - } - .show(requireContext()) - } - lifecycleScope.launch { - viewModel.playbackState.collect { playbackState -> - if (playbackState.state != STATE_NONE && playbackState.state != STATE_STOPPED) { - if (mainActivity.bottomSheetBehavior.state == STATE_HIDDEN) { - mainActivity.bottomSheetBehavior.state = STATE_COLLAPSED - } - } - binding.lyricsView.isPlaying = playbackState.state == STATE_PLAYING || playbackState.state == STATE_BUFFERING - } - } - lifecycleScope.launch { - viewModel.position.collect { position -> - if (!sliderIsTracking && binding.slider.isEnabled) { - binding.slider.value = position.toFloat().coerceIn(binding.slider.valueFrom, binding.slider.valueTo) - binding.position.text = makeTimeString(position) - binding.lyricsView.updateTime(position, animate = true) - } - } - } - lifecycleScope.launch { - viewModel.duration.collect { duration -> - if (duration <= 0) { - binding.slider.isEnabled = false - binding.duration.text = "" - } else { - binding.slider.isEnabled = true - binding.slider.valueTo = duration.toFloat() - binding.duration.text = makeTimeString(duration) - } - } - } - lifecycleScope.launch { - viewModel.currentSong.collectLatest { song -> - binding.btnFavorite.setImageResource(if (song?.song?.liked == true) R.drawable.ic_favorite else R.drawable.ic_favorite_border) - } - } - lifecycleScope.launch { - viewModel.currentLyrics.collectLatest { lyrics -> - if (lyrics == null) { - binding.lyricsView.reset() - } else { - binding.lyricsView.loadLyrics(lyrics.lyrics.takeIf { it != LYRICS_NOT_FOUND } ?: getString(R.string.lyrics_not_found)) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/HomeFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/HomeFragment.kt deleted file mode 100644 index b5cd5a96c..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/HomeFragment.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.appcompat.widget.Toolbar -import androidx.core.view.MenuProvider -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import com.zionhuang.innertube.YouTube.HOME_BROWSE_ID -import com.zionhuang.innertube.models.BrowseEndpoint -import com.zionhuang.music.R -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding -import com.zionhuang.music.ui.adapters.YouTubeItemPagingAdapter -import com.zionhuang.music.ui.fragments.base.PagingRecyclerViewFragment -import com.zionhuang.music.utils.NavigationEndpointHandler -import com.zionhuang.music.viewmodels.YouTubeBrowseViewModel -import com.zionhuang.music.viewmodels.YouTubeBrowseViewModelFactory -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class HomeFragment : PagingRecyclerViewFragment(), MenuProvider { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) - override fun getToolbar(): Toolbar = binding.toolbar - - private val viewModel by viewModels { - YouTubeBrowseViewModelFactory( - requireActivity().application, - BrowseEndpoint(browseId = HOME_BROWSE_ID) - ) - } - override val adapter = YouTubeItemPagingAdapter(NavigationEndpointHandler(this)) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) - lifecycleScope.launch { - viewModel.pagingData.collectLatest { - adapter.submitData(it) - } - } - - requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - } - - override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.search_and_settings, menu) - menu.findItem(R.id.action_search).actionView = null - } - - override fun onMenuItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_search -> findNavController().navigate(R.id.youtubeSuggestionFragment) - R.id.action_settings -> findNavController().navigate(R.id.settingsActivity) - } - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/LocalSearchFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/LocalSearchFragment.kt deleted file mode 100644 index 27ed7dd41..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/LocalSearchFragment.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.app.Activity.RESULT_OK -import android.content.Intent -import android.os.Bundle -import android.speech.RecognizerIntent -import android.speech.RecognizerIntent.ACTION_RECOGNIZE_SPEECH -import android.view.View -import android.view.inputmethod.EditorInfo.IME_ACTION_PREVIOUS -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.view.isVisible -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.innertube.models.BrowseEndpoint.Companion.albumBrowseEndpoint -import com.zionhuang.innertube.models.BrowseEndpoint.Companion.artistBrowseEndpoint -import com.zionhuang.innertube.models.BrowseEndpoint.Companion.playlistBrowseEndpoint -import com.zionhuang.music.R -import com.zionhuang.music.databinding.FragmentSearchLocalBinding -import com.zionhuang.music.db.entities.Album -import com.zionhuang.music.db.entities.Artist -import com.zionhuang.music.db.entities.Playlist -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.extensions.getTextChangeFlow -import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.playback.MediaSessionConnection -import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.ui.adapters.LocalItemAdapter -import com.zionhuang.music.ui.fragments.base.AbsRecyclerViewFragment -import com.zionhuang.music.ui.fragments.songs.ArtistSongsFragmentArgs -import com.zionhuang.music.ui.fragments.songs.PlaylistSongsFragmentArgs -import com.zionhuang.music.utils.KeyboardUtil.hideKeyboard -import com.zionhuang.music.utils.KeyboardUtil.showKeyboard -import com.zionhuang.music.utils.NavigationEndpointHandler -import com.zionhuang.music.viewmodels.LocalSearchViewModel -import com.zionhuang.music.viewmodels.LocalSearchViewModel.Filter -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.launch - -class LocalSearchFragment : AbsRecyclerViewFragment() { - override fun getViewBinding() = FragmentSearchLocalBinding.inflate(layoutInflater) - override fun getToolbar() = binding.toolbar - override fun getRecyclerView() = binding.recyclerView - - private val viewModel by viewModels() - override val adapter = LocalItemAdapter() - - private val voiceResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - val spokenText = it.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull() - if (spokenText != null) { - binding.searchView.setText(spokenText) - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.recyclerView.apply { - setHasFixedSize(true) - layoutManager = LinearLayoutManager(requireContext()) - adapter = this@LocalSearchFragment.adapter - } - binding.recyclerView.addOnClickListener { position, _ -> - when (val item = adapter.currentList[position]) { - is Song -> { - val songs = adapter.currentList.filterIsInstance() - MediaSessionConnection.binder?.songPlayer?.playQueue(ListQueue( - title = getString(R.string.queue_searched_songs), - items = songs.map { it.toMediaItem() }, - startIndex = songs.indexOfFirst { it.id == item.id } - )) - } - is Artist -> if (item.artist.isYouTubeArtist) { - NavigationEndpointHandler(this).handle(artistBrowseEndpoint(item.id)) - } else { - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - findNavController().navigate(R.id.artistSongsFragment, ArtistSongsFragmentArgs.Builder(item.id).build().toBundle()) - } - is Album -> NavigationEndpointHandler(this).handle(albumBrowseEndpoint(item.id)) - is Playlist -> if (item.playlist.isYouTubePlaylist) { - NavigationEndpointHandler(this).handle(playlistBrowseEndpoint("VL" + item.id)) - } else { - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - findNavController().navigate(R.id.playlistSongsFragment, PlaylistSongsFragmentArgs.Builder(item.id).build().toBundle()) - } - else -> {} - } - } - - binding.btnVoice.setOnClickListener { - voiceResultLauncher.launch(Intent(ACTION_RECOGNIZE_SPEECH)) - } - setupSearchView() - showKeyboard() - when (viewModel.filter.value) { - Filter.ALL -> binding.chipAll - Filter.SONG -> binding.chipSongs - Filter.ALBUM -> binding.chipAlbums - Filter.ARTIST -> binding.chipArtists - Filter.PLAYLIST -> binding.chipPlaylists - }.isChecked = true - - binding.chipGroup.setOnCheckedStateChangeListener { group, _ -> - viewModel.filter.value = when (group.checkedChipId) { - R.id.chip_all -> Filter.ALL - R.id.chip_songs -> Filter.SONG - R.id.chip_albums -> Filter.ALBUM - R.id.chip_artists -> Filter.ARTIST - R.id.chip_playlists -> Filter.PLAYLIST - else -> Filter.ALL - } - } - - lifecycleScope.launch { - viewModel.result.collectLatest { list -> - adapter.submitList(list) - } - } - } - - @OptIn(FlowPreview::class) - private fun setupSearchView() { - lifecycleScope.launch { - binding.searchView - .getTextChangeFlow() - .debounce(100L) - .collectLatest { - viewModel.query.postValue(it) - binding.btnClear.isVisible = it.isNotEmpty() - } - } - binding.searchView.setOnEditorActionListener { _, actionId, _ -> - if (actionId == IME_ACTION_PREVIOUS) { - hideKeyboard() - true - } else { - false - } - } - binding.btnClear.setOnClickListener { - binding.searchView.text.clear() - } - } - - override fun onPause() { - super.onPause() - hideKeyboard() - } - - private fun showKeyboard() = showKeyboard(requireActivity(), binding.searchView) - private fun hideKeyboard() = hideKeyboard(requireActivity(), binding.searchView) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/MenuBottomSheetDialogFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/MenuBottomSheetDialogFragment.kt deleted file mode 100644 index 3fbccf855..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/MenuBottomSheetDialogFragment.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.os.Bundle -import android.view.* -import androidx.annotation.MenuRes -import androidx.core.os.bundleOf -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.zionhuang.innertube.models.ArtistItem -import com.zionhuang.innertube.models.PlaylistItem -import com.zionhuang.innertube.models.YTItem -import com.zionhuang.music.R -import com.zionhuang.music.databinding.MenuBottomSheetDialogBinding -import com.zionhuang.music.utils.NavigationEndpointHandler -import java.io.Serializable - -typealias MenuModifier = Menu.() -> Unit -typealias MenuItemClickListener = (MenuItem) -> Unit - -class MenuBottomSheetDialogFragment : BottomSheetDialogFragment() { - private lateinit var binding: MenuBottomSheetDialogBinding - - @MenuRes - private var menuResId: Int = 0 - private var menuModifier: MenuModifier? = null - private var onMenuItemClicked: MenuItemClickListener? = null - - @Suppress("UNCHECKED_CAST") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - menuResId = requireArguments().getInt(KEY_MENU_RES_ID, 0) - menuModifier = requireArguments().getSerializable(KEY_MENU_MODIFIER) as? MenuModifier - onMenuItemClicked = requireArguments().getSerializable(KEY_MENU_LISTENER) as? MenuItemClickListener - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = MenuBottomSheetDialogBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.navigationView.background = binding.dragHandle.background - binding.navigationView.apply { - inflateMenu(menuResId) - menuModifier?.invoke(menu) - setNavigationItemSelectedListener { - onMenuItemClicked?.invoke(it) - dismiss() - true - } - } - } - - fun setMenuModifier(menuModifier: MenuModifier): MenuBottomSheetDialogFragment { - requireArguments().putSerializable(KEY_MENU_MODIFIER, menuModifier as Serializable) - return this - } - - fun setOnMenuItemClickListener(listener: MenuItemClickListener): MenuBottomSheetDialogFragment { - requireArguments().putSerializable(KEY_MENU_LISTENER, listener as Serializable) - return this - } - - companion object { - private const val KEY_MENU_RES_ID = "MENU_RES_ID" - private const val KEY_MENU_MODIFIER = "MENU_MODIFIER" - private const val KEY_MENU_LISTENER = "LISTENER" - - fun newInstance(@MenuRes menuResId: Int) = MenuBottomSheetDialogFragment().apply { - arguments = bundleOf(KEY_MENU_RES_ID to menuResId) - } - - fun newInstance(item: YTItem, navigationEndpointHandler: NavigationEndpointHandler) = newInstance(R.menu.youtube_item) - .setMenuModifier { - findItem(R.id.action_radio)?.isVisible = item.menu.radioEndpoint != null - findItem(R.id.action_play_next)?.isVisible = item.menu.playNextEndpoint != null - findItem(R.id.action_add_to_queue)?.isVisible = item.menu.addToQueueEndpoint != null - findItem(R.id.action_add_to_library)?.isVisible = item !is ArtistItem - findItem(R.id.action_import_playlist)?.isVisible = item is PlaylistItem - findItem(R.id.action_add_to_playlist)?.isVisible = item !is ArtistItem - findItem(R.id.action_download)?.isVisible = item !is ArtistItem - findItem(R.id.action_view_artist)?.isVisible = item.menu.artistEndpoint != null - findItem(R.id.action_view_album)?.isVisible = item.menu.albumEndpoint != null - } - .setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.action_radio -> navigationEndpointHandler.handle(item.menu.radioEndpoint) - R.id.action_play_next -> navigationEndpointHandler.playNext(item) - R.id.action_add_to_queue -> navigationEndpointHandler.addToQueue(item) - R.id.action_add_to_library -> navigationEndpointHandler.addToLibrary(item) - R.id.action_import_playlist -> if (item is PlaylistItem) { - navigationEndpointHandler.importPlaylist(item) - } - R.id.action_add_to_playlist -> navigationEndpointHandler.addToPlaylist(item) - R.id.action_download -> {} - R.id.action_view_artist -> navigationEndpointHandler.handle(item.menu.artistEndpoint) - R.id.action_view_album -> navigationEndpointHandler.handle(item.menu.albumEndpoint) - R.id.action_share -> navigationEndpointHandler.share(item) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/MiniPlayerFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/MiniPlayerFragment.kt deleted file mode 100644 index 89007d395..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/MiniPlayerFragment.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.os.Bundle -import android.support.v4.media.session.PlaybackStateCompat.STATE_BUFFERING -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior.STATE_EXPANDED -import com.zionhuang.music.databinding.FragmentMiniPlayerBinding -import com.zionhuang.music.ui.activities.MainActivity -import com.zionhuang.music.viewmodels.PlaybackViewModel -import dev.chrisbanes.insetter.applyInsetter -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class MiniPlayerFragment : Fragment() { - private lateinit var binding: FragmentMiniPlayerBinding - private val viewModel by activityViewModels() - private val mainActivity: MainActivity get() = requireActivity() as MainActivity - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentMiniPlayerBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = this - binding.viewModel = viewModel - - binding.root.applyInsetter { - type(navigationBars = true) { - padding(horizontal = true) - } - } - // Marquee - binding.songTitle.isSelected = true - binding.songArtist.isSelected = true - - binding.root.setOnClickListener { - mainActivity.bottomSheetBehavior.state = STATE_EXPANDED - } - lifecycleScope.launch { - viewModel.playbackState.collectLatest { - binding.progressBar.isIndeterminate = it.state == STATE_BUFFERING - } - } - lifecycleScope.launch { - viewModel.position.collect { position -> - binding.progressBar.progress = position.toInt() - } - } - lifecycleScope.launch { - viewModel.duration.collect { duration -> - binding.progressBar.max = duration.toInt() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/PlaylistsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/PlaylistsFragment.kt deleted file mode 100644 index 4eeeea40a..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/PlaylistsFragment.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.core.view.MenuProvider -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.innertube.models.BrowseEndpoint -import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.DOWNLOADED_PLAYLIST_ID -import com.zionhuang.music.constants.Constants.LIKED_PLAYLIST_ID -import com.zionhuang.music.db.entities.Playlist -import com.zionhuang.music.extensions.addFastScroller -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.ui.activities.MainActivity -import com.zionhuang.music.ui.adapters.LocalItemAdapter -import com.zionhuang.music.ui.adapters.selection.LocalItemDetailsLookup -import com.zionhuang.music.ui.adapters.selection.LocalItemKeyProvider -import com.zionhuang.music.ui.fragments.base.RecyclerViewFragment -import com.zionhuang.music.ui.fragments.dialogs.CreatePlaylistDialog -import com.zionhuang.music.ui.listeners.DownloadedPlaylistMenuListener -import com.zionhuang.music.ui.listeners.LikedPlaylistMenuListener -import com.zionhuang.music.ui.listeners.PlaylistMenuListener -import com.zionhuang.music.utils.NavigationEndpointHandler -import com.zionhuang.music.utils.addActionModeObserver -import com.zionhuang.music.viewmodels.SongsViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class PlaylistsFragment : RecyclerViewFragment(), MenuProvider { - private val songsViewModel by activityViewModels() - private val menuListener = PlaylistMenuListener(this) - override val adapter = LocalItemAdapter().apply { - playlistMenuListener = menuListener - likedPlaylistMenuListener = LikedPlaylistMenuListener(this@PlaylistsFragment) - downloadedPlaylistMenuListener = DownloadedPlaylistMenuListener(this@PlaylistsFragment) - } - private var tracker: SelectionTracker? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - (requireActivity() as MainActivity).fab.setOnClickListener { - CreatePlaylistDialog().show(childFragmentManager, null) - } - - binding.recyclerView.apply { - layoutManager = LinearLayoutManager(requireContext()) - addFastScroller { useMd2Style() } - } - binding.recyclerView.addOnClickListener { position, _ -> - val item = adapter.currentList[position] - if (item is Playlist) { - if (item.playlist.isYouTubePlaylist) { - NavigationEndpointHandler(this).handle(BrowseEndpoint.playlistBrowseEndpoint("VL" + item.id)) - } else { - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - findNavController().navigate(PlaylistsFragmentDirections.actionPlaylistsToPlaylistSongs(item.id)) - } - } else if (item.id == LIKED_PLAYLIST_ID || item.id == DOWNLOADED_PLAYLIST_ID) { - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - findNavController().navigate(PlaylistsFragmentDirections.actionPlaylistsToCustomPlaylist(item.id)) - } - } - - tracker = SelectionTracker.Builder("selectionId", binding.recyclerView, LocalItemKeyProvider(adapter), LocalItemDetailsLookup(binding.recyclerView), StorageStrategy.createStringStorage()) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - adapter.tracker = this - addActionModeObserver(requireActivity(), R.menu.playlist_batch) { item -> - val map = adapter.currentList.associateBy { it.id } - val playlists = selection.toList().map { map[it] }.filterIsInstance() - when (item.itemId) { - R.id.action_play_next -> menuListener.playNext(playlists) - R.id.action_add_to_queue -> menuListener.addToQueue(playlists) - R.id.action_add_to_playlist -> menuListener.addToPlaylist(playlists) - R.id.action_download -> menuListener.download(playlists) - R.id.action_delete -> menuListener.delete(playlists) - } - true - } - } - - lifecycleScope.launch { - songsViewModel.allPlaylistsFlow.collectLatest { - adapter.submitList(it) - } - } - - requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - } - - override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.search_and_settings, menu) - menu.findItem(R.id.action_search).actionView = null - } - - override fun onMenuItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_search -> findNavController().navigate(R.id.localSearchFragment) - R.id.action_settings -> findNavController().navigate(R.id.settingsActivity) - } - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/QueueSheetFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/QueueSheetFragment.kt deleted file mode 100644 index 9f0d268cf..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/QueueSheetFragment.kt +++ /dev/null @@ -1,279 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.content.Intent -import android.graphics.Canvas -import android.media.audiofx.AudioEffect.* -import android.os.Bundle -import android.support.v4.media.MediaMetadataCompat.METADATA_KEY_DURATION -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.edit -import androidx.core.view.ViewCompat -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior.STATE_COLLAPSED -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior.STATE_EXPANDED -import com.google.android.material.snackbar.BaseTransientBottomBar -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.innertube.models.BrowseEndpoint -import com.zionhuang.innertube.models.BrowseLocalArtistSongsEndpoint -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.STATE_NOT_DOWNLOADED -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIBRARY -import com.zionhuang.music.databinding.QueueSheetBinding -import com.zionhuang.music.extensions.* -import com.zionhuang.music.playback.MediaSessionConnection -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.activities.MainActivity -import com.zionhuang.music.ui.adapters.QueueItemAdapter -import com.zionhuang.music.ui.fragments.dialogs.ChoosePlaylistDialog -import com.zionhuang.music.ui.fragments.dialogs.SongDetailsDialog -import com.zionhuang.music.ui.fragments.songs.PlaylistSongsFragmentArgs -import com.zionhuang.music.utils.NavigationEndpointHandler -import com.zionhuang.music.utils.joinByBullet -import com.zionhuang.music.utils.makeTimeString -import com.zionhuang.music.viewmodels.PlaybackViewModel -import dev.chrisbanes.insetter.applyInsetter -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class QueueSheetFragment : Fragment() { - lateinit var binding: QueueSheetBinding - private val viewModel by activityViewModels() - private val mainActivity: MainActivity get() = requireActivity() as MainActivity - private val activityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {} - - private val dragEventManager = DragEventManager() - private val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { - private val elevation by lazy { requireContext().resources.getDimension(R.dimen.drag_item_elevation) } - - override fun isLongPressDragEnabled(): Boolean = false - - override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { - super.onSelectedChanged(viewHolder, actionState) - when (actionState) { - ItemTouchHelper.ACTION_STATE_DRAG -> dragEventManager.postDragStart(viewHolder?.absoluteAdapterPosition) - } - } - - override fun onChildDraw(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) { - super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) - if (isCurrentlyActive) { - ViewCompat.setElevation(viewHolder.itemView, elevation) - } - } - - override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - super.clearView(recyclerView, viewHolder) - ViewCompat.setElevation(viewHolder.itemView, 0f) - dragEventManager.postDragEnd(viewHolder.absoluteAdapterPosition) - } - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - val from = viewHolder.absoluteAdapterPosition - val to = target.absoluteAdapterPosition - adapter.moveItem(from, to) - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val index = viewHolder.absoluteAdapterPosition - adapter.removeItem(index) - MediaSessionConnection.binder?.songPlayer?.player?.removeMediaItem(index) - } - }) - - private val songRepository by lazy { SongRepository(requireContext()) } - private val adapter: QueueItemAdapter = QueueItemAdapter(itemTouchHelper) - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = QueueSheetBinding.inflate(inflater, container, false) - return binding.root - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = this - binding.viewModel = viewModel - - binding.content.applyInsetter { - type(statusBars = true, navigationBars = true) { - padding() - } - } - binding.recyclerView.adapter = adapter - binding.recyclerView.apply { - layoutManager = LinearLayoutManager(requireContext()) - itemTouchHelper.attachToRecyclerView(this) - } - binding.recyclerView.addOnClickListener { pos, _ -> - viewModel.mediaController?.seekToQueueItem(pos) - } - - binding.btnQueue.setOnClickListener { - mainActivity.queueSheetBehavior.state = STATE_EXPANDED - } - binding.btnCollapse.setOnClickListener { - mainActivity.queueSheetBehavior.state = STATE_COLLAPSED - } - binding.btnLyrics.setOnClickListener { - sharedPreferences.edit { - putBoolean(getString(R.string.pref_show_lyrics), !sharedPreferences.getBoolean(getString(R.string.pref_show_lyrics), false)) - } - } - binding.btnAddToLibrary.setOnClickListener { - viewModel.transportControls?.sendCustomAction(ACTION_TOGGLE_LIBRARY, null) - } - binding.btnMore.setOnClickListener { - val mediaMetadata = MediaSessionConnection.binder?.songPlayer?.currentMediaMetadata?.value ?: return@setOnClickListener - val song = MediaSessionConnection.binder?.songPlayer?.currentSong - MenuBottomSheetDialogFragment - .newInstance(R.menu.playing_song) - .setMenuModifier { - findItem(R.id.action_download).isVisible = song == null || song.song.downloadState == STATE_NOT_DOWNLOADED - findItem(R.id.action_view_artist).isVisible = mediaMetadata.artists.isNotEmpty() - findItem(R.id.action_view_album).isVisible = mediaMetadata.album != null - } - .setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_info -> SongDetailsDialog().show(requireContext()) - R.id.action_equalizer -> { - val equalizerIntent = Intent(ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { - putExtra(EXTRA_AUDIO_SESSION, MediaSessionConnection.binder?.songPlayer?.player?.audioSessionId) - putExtra(EXTRA_PACKAGE_NAME, requireContext().packageName) - putExtra(EXTRA_CONTENT_TYPE, CONTENT_TYPE_MUSIC) - } - if (equalizerIntent.resolveActivity(requireContext().packageManager) != null) { - activityResultLauncher.launch(equalizerIntent) - } - } - R.id.action_radio -> MediaSessionConnection.binder?.songPlayer?.startRadioSeamlessly() - R.id.action_add_to_playlist -> { - val mainContent = mainActivity.binding.mainContent - ChoosePlaylistDialog { playlist -> - GlobalScope.launch(requireContext().exceptionHandler) { - if (song != null) songRepository.addToPlaylist(playlist, song) - else songRepository.addMediaItemToPlaylist(playlist, mediaMetadata) - Snackbar.make(mainContent, getString(R.string.snackbar_added_to_playlist, playlist.name), BaseTransientBottomBar.LENGTH_SHORT) - .setAction(R.string.snackbar_action_view) { - mainActivity.currentFragment?.exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - mainActivity.currentFragment?.reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - mainActivity.currentFragment?.findNavController()?.navigate(R.id.playlistSongsFragment, PlaylistSongsFragmentArgs.Builder(playlist.id).build().toBundle()) - }.show() - } - }.show(childFragmentManager, null) - } - R.id.action_download -> { - GlobalScope.launch(requireContext().exceptionHandler) { - songRepository.downloadSong(song?.song ?: songRepository.addSong(mediaMetadata)) - } - } - R.id.action_view_artist -> { - if (mediaMetadata.artists.isNotEmpty()) { - val artist = mediaMetadata.artists[0] - NavigationEndpointHandler(mainActivity.currentFragment!!).handle(if (artist.id.startsWith("UC")) { - BrowseEndpoint.artistBrowseEndpoint(artist.id) - } else { - BrowseLocalArtistSongsEndpoint(artist.id) - }) - mainActivity.collapseBottomSheet() - } - } - R.id.action_view_album -> { - if (mediaMetadata.album != null) { - NavigationEndpointHandler(mainActivity.currentFragment!!).handle(BrowseEndpoint.albumBrowseEndpoint(mediaMetadata.album.id)) - mainActivity.collapseBottomSheet() - } - } - R.id.action_share -> { - val intent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaMetadata.id}") - } - startActivity(Intent.createChooser(intent, null)) - } - } - } - .show(requireContext()) - } - - var queueSheetPrevState = mainActivity.queueSheetBehavior.state - mainActivity.queueSheetBehavior.addBottomSheetCallback(object : NeoBottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (queueSheetPrevState == STATE_COLLAPSED) { - MediaSessionConnection.binder?.songPlayer?.player?.currentMediaItemIndex?.let { - (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(it, 0) - } - } - queueSheetPrevState = newState - } - - override fun onSlide(bottomSheet: View, slideOffset: Float) { - onSlide(slideOffset) - } - }) - onSlide(if (mainActivity.queueSheetBehavior.state == STATE_EXPANDED) 1f else 0f) - - lifecycleScope.launch { - viewModel.currentSong.collectLatest { song -> - binding.btnAddToLibrary.setImageResource(if (song != null) R.drawable.ic_library_add_check else R.drawable.ic_library_add) - } - } - - lifecycleScope.launch { - viewModel.queueItems.collectLatest { items -> - adapter.submitData(items) - binding.queueInfo.text = listOf( - resources.getQuantityString(R.plurals.song_count, items.size, items.size), - makeTimeString(items.sumOf { item -> - item.description.extras?.getLong(METADATA_KEY_DURATION) ?: 0 - }) - ).joinByBullet() - } - } - - dragEventManager.onDragged = { fromPos, toPos -> - viewModel.mediaController?.moveQueueItem(fromPos, toPos) - } - } - - private fun onSlide(slideOffset: Float) { - val progress = slideOffset.coerceIn(0f, 1f) - binding.actionBar.alpha = (1 - progress * 4).coerceIn(0f, 1f) - binding.actionBar.isVisible = 1 - progress * 4 > 0 - binding.content.alpha = ((progress - 0.25f) * 4).coerceIn(0f, 1f) - binding.content.isVisible = progress > 0 - } - - class DragEventManager { - private var dragFromPosition: Int? = null - var onDragged: ((fromPos: Int, toPos: Int) -> Unit)? = null - - fun postDragStart(pos: Int?) { - if (pos == null) return - dragFromPosition = pos - } - - fun postDragEnd(pos: Int?) { - if (pos == null) return - dragFromPosition?.let { fromPos -> - dragFromPosition = null - onDragged?.invoke(fromPos, pos) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/SettingsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/SettingsFragment.kt deleted file mode 100644 index 3b1b32907..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/SettingsFragment.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.os.Bundle -import androidx.navigation.fragment.findNavController -import androidx.preference.PreferenceScreen -import com.zionhuang.music.R -import com.zionhuang.music.ui.fragments.base.BaseSettingsFragment - -class SettingsFragment : BaseSettingsFragment() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_main) - - findPreference(getString(R.string.pref_appearance))?.setOnPreferenceClickListener { - findNavController().navigate(R.id.appearanceSettingsFragment) - true - } - findPreference(getString(R.string.pref_content))?.setOnPreferenceClickListener { - findNavController().navigate(R.id.contentSettingsFragment) - true - } - findPreference(getString(R.string.pref_player_audio))?.setOnPreferenceClickListener { - findNavController().navigate(R.id.playerAudioSettingsFragment) - true - } - findPreference(getString(R.string.pref_storage))?.setOnPreferenceClickListener { - findNavController().navigate(R.id.storageSettingsFragment) - true - } - findPreference(getString(R.string.pref_general))?.setOnPreferenceClickListener { - findNavController().navigate(R.id.generalSettingsFragment) - true - } - findPreference(getString(R.string.pref_privacy))?.setOnPreferenceClickListener { - findNavController().navigate(R.id.privacySettingsFragment) - true - } - findPreference(getString(R.string.pref_backup_restore))?.setOnPreferenceClickListener { - findNavController().navigate(R.id.backupRestoreSettingsFragment) - true - } - findPreference(getString(R.string.pref_about))?.setOnPreferenceClickListener { - findNavController().navigate(R.id.aboutFragment) - true - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/SongsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/SongsFragment.kt deleted file mode 100644 index 732b53845..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/SongsFragment.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.core.view.MenuProvider -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.LinearLayoutManager -import com.zionhuang.music.R -import com.zionhuang.music.db.entities.LocalItem -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.addFastScroller -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.ui.adapters.LocalItemAdapter -import com.zionhuang.music.ui.adapters.selection.LocalItemDetailsLookup -import com.zionhuang.music.ui.adapters.selection.LocalItemKeyProvider -import com.zionhuang.music.ui.fragments.base.RecyclerViewFragment -import com.zionhuang.music.ui.listeners.SongMenuListener -import com.zionhuang.music.utils.addActionModeObserver -import com.zionhuang.music.viewmodels.PlaybackViewModel -import com.zionhuang.music.viewmodels.SongsViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class SongsFragment : RecyclerViewFragment(), MenuProvider { - private val playbackViewModel by activityViewModels() - private val songsViewModel by activityViewModels() - private val menuListener = SongMenuListener(this) - override val adapter = LocalItemAdapter().apply { - songMenuListener = menuListener - } - private var tracker: SelectionTracker? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.recyclerView.apply { - layoutManager = LinearLayoutManager(requireContext()) - setHasFixedSize(true) - addFastScroller { useMd2Style() } - } - binding.recyclerView.addOnClickListener { position, _ -> - if (adapter.currentList[position] !is LocalItem) return@addOnClickListener - - playbackViewModel.playQueue(requireActivity(), ListQueue( - title = getString(R.string.queue_all_songs), - items = adapter.currentList.filterIsInstance().map { it.toMediaItem() }, - startIndex = position - 1 - )) - } - adapter.onShuffle = { - playbackViewModel.playQueue(requireActivity(), ListQueue( - title = getString(R.string.queue_all_songs), - items = adapter.currentList.filterIsInstance().shuffled().map { it.toMediaItem() } - )) - } - - tracker = SelectionTracker.Builder("selectionId", binding.recyclerView, LocalItemKeyProvider(adapter), LocalItemDetailsLookup(binding.recyclerView), StorageStrategy.createStringStorage()) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - adapter.tracker = this - addActionModeObserver(requireActivity(), R.menu.song_batch) { item -> - val map = adapter.currentList.associateBy { it.id } - val songs = selection.toList().map { map[it] }.filterIsInstance() - when (item.itemId) { - R.id.action_play_next -> menuListener.playNext(songs) - R.id.action_add_to_queue -> menuListener.addToQueue(songs) - R.id.action_add_to_playlist -> menuListener.addToPlaylist(songs) - R.id.action_download -> menuListener.download(songs) - R.id.action_remove_download -> menuListener.removeDownload(songs) - R.id.action_refetch -> menuListener.refetch(songs) - R.id.action_delete -> menuListener.delete(songs) - } - true - } - } - - lifecycleScope.launch { - songsViewModel.allSongsFlow.collectLatest { - adapter.submitList(it) - } - } - - requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - } - - override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.search_and_settings, menu) - menu.findItem(R.id.action_search).actionView = null - } - - override fun onMenuItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_search -> findNavController().navigate(R.id.localSearchFragment) - R.id.action_settings -> findNavController().navigate(R.id.settingsActivity) - } - return true - } - - override fun onViewStateRestored(savedInstanceState: Bundle?) { - super.onViewStateRestored(savedInstanceState) - tracker?.onRestoreInstanceState(savedInstanceState) - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - tracker?.onSaveInstanceState(outState) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/WebViewFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/WebViewFragment.kt deleted file mode 100644 index db2279478..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/WebViewFragment.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.View -import android.webkit.CookieManager -import android.webkit.JavascriptInterface -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.core.content.edit -import com.zionhuang.innertube.YouTube -import com.zionhuang.music.constants.Constants.ACCOUNT_EMAIL -import com.zionhuang.music.constants.Constants.ACCOUNT_NAME -import com.zionhuang.music.constants.Constants.INNERTUBE_COOKIE -import com.zionhuang.music.constants.Constants.VISITOR_DATA -import com.zionhuang.music.databinding.FragmentWebviewBinding -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.ui.fragments.base.BindingFragment -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -class WebViewFragment : BindingFragment() { - override fun getViewBinding() = FragmentWebviewBinding.inflate(layoutInflater) - - @OptIn(DelicateCoroutinesApi::class) - private val webViewClient = object : WebViewClient() { - override fun doUpdateVisitedHistory(view: WebView, url: String, isReload: Boolean) { - if (url.startsWith("https://music.youtube.com")) { - val cookies = CookieManager.getInstance().getCookie(url) - if (sharedPreferences.getString(INNERTUBE_COOKIE, null) != cookies) { - sharedPreferences.edit { - putString(INNERTUBE_COOKIE, cookies) - } - GlobalScope.launch { - YouTube.getAccountInfo().onSuccess { - sharedPreferences.edit { - putString(ACCOUNT_NAME, it?.name) - putString(ACCOUNT_EMAIL, it?.email) - } - }.onFailure { - it.printStackTrace() - } - } - } - } - } - - override fun onPageFinished(view: WebView, url: String?) { - binding.webview.loadUrl("javascript:Android.onRetrieveVisitorData(window.yt.config_.VISITOR_DATA)") - } - } - - @SuppressLint("SetJavaScriptEnabled") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.webview.apply { - if (savedInstanceState != null) { - restoreState(savedInstanceState) - } else { - loadUrl("https://accounts.google.com/ServiceLogin?ltmpl=music&service=youtube&passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26next%3Dhttps%253A%252F%252Fmusic.youtube.com%252F") - } - webViewClient = this@WebViewFragment.webViewClient - settings.apply { - javaScriptEnabled = true - setSupportZoom(true) - builtInZoomControls = true - } - addJavascriptInterface(this@WebViewFragment, "Android") - } - } - - @JavascriptInterface - fun onRetrieveVisitorData(visitorData: String?) { - if (visitorData != null && sharedPreferences.getString(VISITOR_DATA, null) != visitorData) { - sharedPreferences.edit { - putString(VISITOR_DATA, visitorData) - } - } - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - binding.webview.saveState(outState) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/base/BaseSettingsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/base/BaseSettingsFragment.kt deleted file mode 100644 index fcd7d8ddf..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/base/BaseSettingsFragment.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.zionhuang.music.ui.fragments.base - -import androidx.preference.EditTextPreference -import androidx.preference.ListPreference -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.databinding.DialogEditTextPreferenceBinding -import com.zionhuang.music.extensions.sharedPreferences - -abstract class BaseSettingsFragment : PreferenceFragmentCompat() { - /** - * Show [ListPreference] and [EditTextPreference] dialog by [MaterialAlertDialogBuilder] - */ - override fun onDisplayPreferenceDialog(preference: Preference) { - when (preference) { - is ListPreference -> { - val prefIndex = preference.entryValues.indexOf(preference.value) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(preference.title) - .setSingleChoiceItems(preference.entries, prefIndex) { dialog, index -> - val newValue = preference.entryValues[index].toString() - if (preference.callChangeListener(newValue)) { - preference.value = newValue - } - dialog.dismiss() - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - is EditTextPreference -> { - val binding = DialogEditTextPreferenceBinding.inflate(layoutInflater) - binding.editText.setText(sharedPreferences.getString(preference.key, "")) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(preference.title) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - val newValue = binding.editText.text.toString() - if (preference.callChangeListener(newValue)) { - preference.text = newValue - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - else -> super.onDisplayPreferenceDialog(preference) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/base/BindingFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/base/BindingFragment.kt deleted file mode 100644 index c9558dfda..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/base/BindingFragment.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zionhuang.music.ui.fragments.base - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.viewbinding.ViewBinding - -abstract class BindingFragment : Fragment() { - protected lateinit var binding: T - - abstract fun getViewBinding(): T - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - binding = getViewBinding() - return binding.root - } -} diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/base/NavigationFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/base/NavigationFragment.kt deleted file mode 100644 index aeba5d9f2..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/base/NavigationFragment.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.zionhuang.music.ui.fragments.base - -import android.os.Bundle -import android.view.View -import androidx.annotation.CallSuper -import androidx.appcompat.widget.Toolbar -import androidx.navigation.fragment.findNavController -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupWithNavController -import androidx.viewbinding.ViewBinding -import com.zionhuang.music.R -import com.zionhuang.music.extensions.requireAppCompatActivity - -abstract class NavigationFragment : BindingFragment() { - abstract fun getToolbar(): Toolbar - - @CallSuper - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - requireAppCompatActivity().setSupportActionBar(getToolbar()) - val appBarConfiguration = AppBarConfiguration(setOf( - R.id.homeFragment, - R.id.songsFragment, - R.id.artistsFragment, - R.id.albumsFragment, - R.id.playlistsFragment - )) - getToolbar().setupWithNavController(findNavController(), appBarConfiguration) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/base/PagingRecyclerViewFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/base/PagingRecyclerViewFragment.kt deleted file mode 100644 index fd33d1294..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/base/PagingRecyclerViewFragment.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.zionhuang.music.ui.fragments.base - -import android.os.Bundle -import android.view.View -import androidx.annotation.CallSuper -import androidx.appcompat.widget.Toolbar -import androidx.lifecycle.lifecycleScope -import androidx.paging.LoadState -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import androidx.viewbinding.ViewBinding -import com.zionhuang.music.databinding.LayoutLoadStateBinding -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding -import com.zionhuang.music.ui.adapters.LoadStateAdapter -import com.zionhuang.music.utils.bindLoadStateLayout -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -abstract class AbsPagingRecyclerViewFragment> : AbsRecyclerViewFragment() { - open fun getLayoutLoadState(): LayoutLoadStateBinding? = null - open fun getSwipeRefreshLayout(): SwipeRefreshLayout? = null - val refreshable: Boolean = true - - @CallSuper - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - getLayoutLoadState()?.let { loadStateBinding -> - adapter.bindLoadStateLayout(loadStateBinding, isSwipeRefreshing = { - getSwipeRefreshLayout()?.isRefreshing ?: false - }) - } - getSwipeRefreshLayout()?.let { swipeRefreshLayout -> - swipeRefreshLayout.isEnabled = refreshable - swipeRefreshLayout.setOnRefreshListener { - adapter.refresh() - } - lifecycleScope.launch { - adapter.loadStateFlow.collectLatest { loadStates -> - if (loadStates.refresh !is LoadState.Loading && swipeRefreshLayout.isRefreshing) { - swipeRefreshLayout.isRefreshing = false - } - } - } - } - } - - override fun setupRecyclerView(recyclerView: RecyclerView) { - recyclerView.adapter = adapter.withLoadStateFooter(LoadStateAdapter { adapter.retry() }) - } -} - -abstract class PagingRecyclerViewFragment> : AbsPagingRecyclerViewFragment() { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) - override fun getToolbar(): Toolbar = binding.toolbar - override fun getRecyclerView() = binding.recyclerView - override fun getLayoutLoadState() = binding.layoutLoadState - override fun getSwipeRefreshLayout() = binding.swipeRefresh - - @CallSuper - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/base/RecyclerViewFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/base/RecyclerViewFragment.kt deleted file mode 100644 index 490dc8c09..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/base/RecyclerViewFragment.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.zionhuang.music.ui.fragments.base - -import android.os.Bundle -import android.view.View -import androidx.annotation.CallSuper -import androidx.appcompat.widget.Toolbar -import androidx.core.view.doOnPreDraw -import androidx.core.view.updatePadding -import androidx.recyclerview.widget.RecyclerView -import androidx.viewbinding.ViewBinding -import com.google.android.material.transition.MaterialFadeThrough -import com.zionhuang.music.R -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding -import com.zionhuang.music.extensions.systemBarInsetsCompat - -abstract class AbsRecyclerViewFragment> : NavigationFragment() { - abstract fun getRecyclerView(): RecyclerView - abstract val adapter: T - - @CallSuper - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - enterTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()).addTarget(R.id.fragment_content) - exitTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()).addTarget(R.id.fragment_content) - postponeEnterTransition() - view.doOnPreDraw { startPostponedEnterTransition() } - setupRecyclerView(getRecyclerView()) - getRecyclerView().setOnApplyWindowInsetsListener { v, insets -> - v.updatePadding(bottom = insets.systemBarInsetsCompat.bottom) - insets - } - } - - protected open fun setupRecyclerView(recyclerView: RecyclerView) { - recyclerView.adapter = adapter - } -} - -abstract class RecyclerViewFragment> : AbsRecyclerViewFragment() { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) - override fun getToolbar(): Toolbar = binding.toolbar - override fun getRecyclerView() = binding.recyclerView - abstract override val adapter: T - - @CallSuper - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/ChooseLyricsDialog.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/ChooseLyricsDialog.kt deleted file mode 100644 index 6f988ace0..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/ChooseLyricsDialog.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.zionhuang.music.ui.fragments.dialogs - -import android.annotation.SuppressLint -import android.app.Dialog -import android.os.Bundle -import androidx.appcompat.app.AppCompatDialogFragment -import androidx.core.os.bundleOf -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.R -import com.zionhuang.music.databinding.DialogChooseLyricsBinding -import com.zionhuang.music.db.entities.LyricsEntity -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.adapters.LyricsAdapter -import com.zionhuang.music.utils.lyrics.LyricsHelper -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -class ChooseLyricsDialog() : AppCompatDialogFragment() { - private lateinit var binding: DialogChooseLyricsBinding - private val adapter = LyricsAdapter() - - private var mediaId: String? = null - private lateinit var songTitle: String - private lateinit var songArtists: String - private var duration = -1 - - constructor(mediaId: String?, songTitle: String, songArtists: String, duration: Int) : this() { - arguments = bundleOf( - EXTRA_MEDIA_ID to mediaId, - EXTRA_SONG_TITLE to songTitle, - EXTRA_ARTISTS to songArtists, - EXTRA_DURATION to duration - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - mediaId = arguments?.getString(EXTRA_MEDIA_ID) - songTitle = arguments?.getString(EXTRA_SONG_TITLE)!! - songArtists = arguments?.getString(EXTRA_ARTISTS)!! - duration = arguments?.getInt(EXTRA_DURATION) ?: -1 - } - - @OptIn(DelicateCoroutinesApi::class) - @SuppressLint("NotifyDataSetChanged") - private fun setupUI() { - binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) - binding.recyclerView.adapter = adapter - binding.recyclerView.addOnClickListener { position, _ -> - GlobalScope.launch { - mediaId?.let { mediaId -> - SongRepository(requireContext()).upsert(LyricsEntity( - mediaId, - adapter.items[position].lyrics - )) - } - } - dismiss() - } - lifecycleScope.launch { - adapter.items = LyricsHelper.getAllLyrics(requireContext(), mediaId, songTitle, songArtists, duration) - adapter.notifyDataSetChanged() - binding.progressBar.isVisible = false - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogChooseLyricsBinding.inflate(layoutInflater) - setupUI() - - return MaterialAlertDialogBuilder(requireContext(), R.style.Dialog) - .setTitle(R.string.dialog_title_choose_lyrics) - .setView(binding.root) - .setNegativeButton(android.R.string.cancel, null) - .create() - } - - companion object { - const val EXTRA_MEDIA_ID = "media_id" - const val EXTRA_SONG_TITLE = "song_title" - const val EXTRA_ARTISTS = "artists" - const val EXTRA_DURATION = "duration" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/ChoosePlaylistDialog.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/ChoosePlaylistDialog.kt deleted file mode 100644 index bb487b771..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/ChoosePlaylistDialog.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.zionhuang.music.ui.fragments.dialogs - -import android.app.Dialog -import android.os.Bundle -import androidx.appcompat.app.AppCompatDialogFragment -import androidx.core.os.bundleOf -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_BLOCK -import com.zionhuang.music.databinding.DialogChoosePlaylistBinding -import com.zionhuang.music.db.entities.Playlist -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.ui.adapters.LocalItemAdapter -import com.zionhuang.music.viewmodels.SongsViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -typealias PlaylistListener = (PlaylistEntity) -> Unit - -class ChoosePlaylistDialog() : AppCompatDialogFragment() { - private lateinit var binding: DialogChoosePlaylistBinding - private val viewModel by activityViewModels() - private val adapter = LocalItemAdapter().apply { - allowMoreAction = false - } - - private var listener: PlaylistListener? = null - - constructor(listener: PlaylistListener) : this() { - arguments = bundleOf(EXTRA_BLOCK to listener) - } - - @Suppress("UNCHECKED_CAST") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - listener = arguments?.getSerializable(EXTRA_BLOCK) as? PlaylistListener - } - - private fun setupUI() { - binding.recyclerView.apply { - layoutManager = LinearLayoutManager(requireContext()) - adapter = this@ChoosePlaylistDialog.adapter - addOnClickListener { position, _ -> - listener?.invoke((this@ChoosePlaylistDialog.adapter.currentList[position] as Playlist).playlist) - dismiss() - } - } - binding.createPlaylist.setOnClickListener { - CreatePlaylistDialog(listener).show(parentFragmentManager, null) - dismiss() - } - - lifecycleScope.launch { - viewModel.allPlaylistsFlow.map { pagingData -> - pagingData.filter { item -> - item is Playlist && item.playlist.isLocalPlaylist - } - }.collectLatest { - adapter.submitList(it) - } - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogChoosePlaylistBinding.inflate(requireActivity().layoutInflater) - setupUI() - - return MaterialAlertDialogBuilder(requireContext(), R.style.Dialog) - .setTitle(R.string.dialog_title_choose_playlist) - .setView(binding.root) - .create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/CreatePlaylistDialog.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/CreatePlaylistDialog.kt deleted file mode 100644 index 02a74fbe3..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/CreatePlaylistDialog.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.zionhuang.music.ui.fragments.dialogs - -import android.app.Dialog -import android.content.DialogInterface.BUTTON_POSITIVE -import android.os.Bundle -import androidx.appcompat.app.AppCompatDialogFragment -import androidx.core.os.bundleOf -import androidx.core.widget.doOnTextChanged -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_BLOCK -import com.zionhuang.music.databinding.DialogSingleTextInputBinding -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.generatePlaylistId -import com.zionhuang.music.extensions.exceptionHandler -import com.zionhuang.music.repos.SongRepository -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -class CreatePlaylistDialog() : AppCompatDialogFragment() { - private lateinit var binding: DialogSingleTextInputBinding - private var listener: PlaylistListener? = null - - constructor(listener: PlaylistListener?) : this() { - arguments = bundleOf(EXTRA_BLOCK to listener) - } - - @Suppress("UNCHECKED_CAST") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - listener = arguments?.getSerializable(EXTRA_BLOCK) as? PlaylistListener - } - - private fun setupUI() { - binding.textInput.apply { - setHint(R.string.text_view_hint_playlist_name) - editText?.doOnTextChanged { text, _, _, _ -> - binding.textInput.error = if (text.isNullOrEmpty()) getString(R.string.error_playlist_name_empty) else null - } - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogSingleTextInputBinding.inflate(requireActivity().layoutInflater) - setupUI() - - return MaterialAlertDialogBuilder(requireContext(), R.style.Dialog) - .setTitle(R.string.dialog_title_create_playlist) - .setView(binding.root) - .setPositiveButton(android.R.string.ok, null) - .setNegativeButton(android.R.string.cancel, null) - .create() - .apply { - setOnShowListener { - getButton(BUTTON_POSITIVE).setOnClickListener { onOK() } - } - } - } - - @OptIn(DelicateCoroutinesApi::class) - private fun onOK() { - if (binding.textInput.editText?.text.isNullOrEmpty()) return - val name = binding.textInput.editText?.text.toString() - val playlist = PlaylistEntity( - id = generatePlaylistId(), - name = name - ) - GlobalScope.launch(requireContext().exceptionHandler) { - SongRepository(requireContext()).insertPlaylist(playlist) - listener?.invoke(playlist) - } - dismiss() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/EditArtistDialog.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/EditArtistDialog.kt deleted file mode 100644 index b570bd609..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/EditArtistDialog.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.zionhuang.music.ui.fragments.dialogs - -import android.app.Dialog -import android.content.DialogInterface.BUTTON_POSITIVE -import android.os.Bundle -import androidx.appcompat.app.AppCompatDialogFragment -import androidx.core.widget.doOnTextChanged -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_ARTIST -import com.zionhuang.music.databinding.DialogSingleTextInputBinding -import com.zionhuang.music.db.entities.ArtistEntity -import com.zionhuang.music.repos.SongRepository -import kotlinx.coroutines.* - -class EditArtistDialog : AppCompatDialogFragment() { - private lateinit var binding: DialogSingleTextInputBinding - private lateinit var artist: ArtistEntity - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - artist = arguments?.getParcelable(EXTRA_ARTIST)!! - } - - private fun setupUI() { - binding.textInput.apply { - setHint(R.string.text_view_hint_artist_name) - editText?.setText(artist.name) - editText?.doOnTextChanged { text, _, _, _ -> - binding.textInput.error = if (text.isNullOrEmpty()) getString(R.string.error_artist_name_empty) else null - } - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogSingleTextInputBinding.inflate(requireActivity().layoutInflater) - setupUI() - - return MaterialAlertDialogBuilder(requireContext(), R.style.Dialog) - .setTitle(R.string.dialog_title_edit_artist) - .setView(binding.root) - .setPositiveButton(R.string.dialog_button_save, null) - .setNegativeButton(android.R.string.cancel, null) - .create() - .apply { - setOnShowListener { - getButton(BUTTON_POSITIVE).setOnClickListener { onOK() } - } - } - } - - @OptIn(DelicateCoroutinesApi::class) - private fun onOK() { - if (binding.textInput.editText?.text.isNullOrEmpty()) return - val name = binding.textInput.editText?.text.toString() - GlobalScope.launch { - val songRepository = SongRepository(requireContext()) - val existedArtist = songRepository.getArtistByName(name) - if (existedArtist != null && existedArtist.id != artist.id) { - // name exists - withContext(Dispatchers.Main) { - MaterialAlertDialogBuilder(requireContext(), R.style.Dialog) - .setTitle(getString(R.string.dialog_title_duplicate_artist)) - .setMessage(getString(R.string.dialog_msg_duplicate_artist, existedArtist.name)) - .setPositiveButton(resources.getString(android.R.string.ok), null) - .show() - } - } else { - songRepository.updateArtist(artist.copy(name = name)) - dismiss() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/EditPlaylistDialog.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/EditPlaylistDialog.kt deleted file mode 100644 index 49c496e33..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/EditPlaylistDialog.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.zionhuang.music.ui.fragments.dialogs - -import android.app.Dialog -import android.content.DialogInterface.BUTTON_POSITIVE -import android.os.Bundle -import androidx.appcompat.app.AppCompatDialogFragment -import androidx.core.widget.doOnTextChanged -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_PLAYLIST -import com.zionhuang.music.databinding.DialogSingleTextInputBinding -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.repos.SongRepository -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -class EditPlaylistDialog : AppCompatDialogFragment() { - private lateinit var binding: DialogSingleTextInputBinding - private lateinit var playlist: PlaylistEntity - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - playlist = arguments?.getParcelable(EXTRA_PLAYLIST)!! - } - - private fun setupUI() { - binding.textInput.apply { - setHint(R.string.text_view_hint_playlist_name) - editText?.setText(playlist.name) - editText?.doOnTextChanged { text, _, _, _ -> - binding.textInput.error = if (text.isNullOrEmpty()) getString(R.string.error_playlist_name_empty) else null - } - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogSingleTextInputBinding.inflate(requireActivity().layoutInflater) - setupUI() - - return MaterialAlertDialogBuilder(requireContext(), R.style.Dialog) - .setTitle(R.string.dialog_title_edit_playlist) - .setView(binding.root) - .setPositiveButton(R.string.dialog_button_save, null) - .setNegativeButton(android.R.string.cancel, null) - .create() - .apply { - setOnShowListener { - getButton(BUTTON_POSITIVE).setOnClickListener { onOK() } - } - } - } - - @OptIn(DelicateCoroutinesApi::class) - private fun onOK() { - if (binding.textInput.editText?.text.isNullOrEmpty()) return - val name = binding.textInput.editText?.text.toString() - GlobalScope.launch { - SongRepository(requireContext()).updatePlaylist(playlist.copy(name = name)) - } - dismiss() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/EditSongDialog.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/EditSongDialog.kt deleted file mode 100644 index f885dd246..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/EditSongDialog.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.zionhuang.music.ui.fragments.dialogs - -import android.app.Dialog -import android.content.DialogInterface.BUTTON_POSITIVE -import android.os.Bundle -import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE -import android.widget.AutoCompleteTextView -import androidx.appcompat.app.AppCompatDialogFragment -import androidx.core.widget.doOnTextChanged -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_SONG -import com.zionhuang.music.databinding.DialogEditSongBinding -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.repos.SongRepository -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - - -class EditSongDialog : AppCompatDialogFragment() { - private lateinit var binding: DialogEditSongBinding - private lateinit var song: Song - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - song = arguments?.getParcelable(EXTRA_SONG)!! - } - - private fun setupUI() { - (binding.songArtist.editText as? AutoCompleteTextView)?.apply { -// setAdapter(ArtistAutoCompleteAdapter(requireContext())) - } - binding.songTitle.editText?.doOnTextChanged { text, _, _, _ -> - binding.songTitle.error = if (text.isNullOrEmpty()) getString(R.string.error_song_title_empty) else null - } - binding.songArtist.editText?.doOnTextChanged { text, _, _, _ -> - binding.songArtist.error = if (text.isNullOrEmpty()) getString(R.string.error_song_artist_empty) else null - } - with(binding) { - songTitle.editText?.setText(song.song.title) - songArtist.editText?.setText(song.artists.joinToString { it.name }) - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogEditSongBinding.inflate(requireActivity().layoutInflater) - setupUI() - - return MaterialAlertDialogBuilder(requireContext(), R.style.Dialog) - .setView(binding.root) - .setTitle(R.string.dialog_title_edit_song) - .setPositiveButton(R.string.dialog_button_save, null) - .setNegativeButton(android.R.string.cancel, null) - .create() - .apply { - window!!.setSoftInputMode(SOFT_INPUT_STATE_VISIBLE) - setOnShowListener { - getButton(BUTTON_POSITIVE).setOnClickListener { onSave() } - } - } - } - - @OptIn(DelicateCoroutinesApi::class) - private fun onSave() { - if (binding.songTitle.error != null || binding.songArtist.error != null) { - return - } - val title = binding.songTitle.editText?.text.toString() - // TODO - GlobalScope.launch { - SongRepository(requireContext()).updateSongTitle(song, title) - } - dismiss() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/NavigationTabConfigDialog.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/NavigationTabConfigDialog.kt deleted file mode 100644 index d1f7ad26b..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/NavigationTabConfigDialog.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.zionhuang.music.ui.fragments.dialogs - -import android.app.Dialog -import android.os.Bundle -import android.widget.Toast -import android.widget.Toast.LENGTH_SHORT -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE -import androidx.appcompat.app.AppCompatDialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.R -import com.zionhuang.music.utils.NavigationTabHelper - -class NavigationTabConfigDialog : AppCompatDialogFragment() { - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val enabledTabs = NavigationTabHelper.getConfig(requireContext()) - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.pref_customize_navigation_tabs) - .setMultiChoiceItems(R.array.bottom_nav_items, enabledTabs) { dialog, index, newState -> - enabledTabs[index] = newState - if (dialog is AlertDialog) { - dialog.getButton(BUTTON_POSITIVE).isEnabled = !enabledTabs.none { it } - } - } - .setPositiveButton(android.R.string.ok) { dialog, _ -> - if (!NavigationTabHelper.getConfig(requireContext()).contentEquals(enabledTabs)) { - NavigationTabHelper.setConfig(requireContext(), enabledTabs) - Toast.makeText(requireContext(), R.string.pref_restart_title, LENGTH_SHORT).show() - } - dialog.dismiss() - } - .setNegativeButton(android.R.string.cancel, null) - .create() - } -} diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/SearchLyricsDialog.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/SearchLyricsDialog.kt deleted file mode 100644 index 0cdc1db30..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/SearchLyricsDialog.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.zionhuang.music.ui.fragments.dialogs - -import android.app.Dialog -import android.os.Bundle -import androidx.appcompat.app.AppCompatDialogFragment -import androidx.core.os.bundleOf -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_MEDIA_METADATA -import com.zionhuang.music.databinding.DialogSearchLyricsBinding -import com.zionhuang.music.models.MediaMetadata - -class SearchLyricsDialog() : AppCompatDialogFragment() { - private lateinit var binding: DialogSearchLyricsBinding - private var mediaMetadata: MediaMetadata? = null - - constructor(mediaMetadata: MediaMetadata) : this() { - arguments = bundleOf(EXTRA_MEDIA_METADATA to mediaMetadata) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - mediaMetadata = arguments?.getParcelable(EXTRA_MEDIA_METADATA) - } - - private fun setupUI() { - binding.songTitle.editText?.setText(mediaMetadata?.title.orEmpty()) - binding.songArtist.editText?.setText(mediaMetadata?.artists?.joinToString { it.name }.orEmpty()) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogSearchLyricsBinding.inflate(layoutInflater) - setupUI() - - return MaterialAlertDialogBuilder(requireContext(), R.style.Dialog) - .setTitle(R.string.dialog_title_search_lyrics) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - ChooseLyricsDialog( - mediaMetadata?.id, - binding.songTitle.editText?.text.toString(), - binding.songArtist.editText?.text.toString(), - mediaMetadata?.duration ?: -1 - ).show(parentFragmentManager, null) - dismiss() - } - .setNegativeButton(android.R.string.cancel, null) - .create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/SongDetailsDialog.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/SongDetailsDialog.kt deleted file mode 100644 index cc850d634..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/SongDetailsDialog.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.zionhuang.music.ui.fragments.dialogs - -import android.annotation.SuppressLint -import android.app.Dialog -import android.content.ClipData -import android.content.ClipboardManager -import android.os.Bundle -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ARTIST -import android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE -import android.text.format.Formatter -import android.widget.Toast -import androidx.appcompat.app.AppCompatDialogFragment -import androidx.core.content.getSystemService -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.R -import com.zionhuang.music.databinding.DialogSongDetailsBinding -import com.zionhuang.music.viewmodels.PlaybackViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class SongDetailsDialog : AppCompatDialogFragment() { - private lateinit var binding: DialogSongDetailsBinding - private val viewModel by activityViewModels() - - @SuppressLint("SetTextI18n") - private fun setupUI() { - listOf(binding.songTitle, binding.songArtist, binding.mediaId, binding.mimeType, binding.codecs, binding.bitrate, binding.sampleRate, binding.loudness, binding.fileSize).forEach { textView -> - textView.setOnClickListener { - val clipboardManager = requireContext().getSystemService()!! - val clip = ClipData.newPlainText(null, textView.text) - clipboardManager.setPrimaryClip(clip) - Toast.makeText(requireContext(), R.string.copied, Toast.LENGTH_SHORT).show() - } - } - lifecycleScope.launch { - viewModel.mediaMetadata.collectLatest { mediaMetadata -> - binding.songTitle.text = mediaMetadata?.getString(METADATA_KEY_TITLE) - binding.songArtist.text = mediaMetadata?.getString(METADATA_KEY_ARTIST) - binding.mediaId.text = mediaMetadata?.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID) - } - } - lifecycleScope.launch { - viewModel.currentSongFormat.collectLatest { format -> - binding.itag.text = format?.itag?.toString() ?: getString(R.string.unknown) - binding.mimeType.text = format?.mimeType ?: getString(R.string.unknown) - binding.codecs.text = format?.codecs ?: getString(R.string.unknown) - binding.bitrate.text = format?.bitrate?.let { "${it / 1000} Kbps" } ?: getString(R.string.unknown) - binding.sampleRate.text = format?.sampleRate?.let { "$it Hz" } ?: getString(R.string.unknown) - binding.loudness.text = format?.loudnessDb?.let { "$it dB" } ?: getString(R.string.unknown) - binding.fileSize.text = format?.contentLength?.let { Formatter.formatShortFileSize(requireContext(), it) } ?: getString(R.string.unknown) - } - } - lifecycleScope.launch { - viewModel.playerVolume.collectLatest { volume -> - binding.volume.text = "${(volume * 100).toInt()}%" - } - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogSongDetailsBinding.inflate(requireActivity().layoutInflater) - setupUI() - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.dialog_title_details) - .setView(binding.root) - .setPositiveButton(android.R.string.ok, null) - .create() - } -} diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/AboutFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/settings/AboutFragment.kt deleted file mode 100644 index 04e2fb080..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/AboutFragment.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.zionhuang.music.ui.fragments.settings - -import android.content.Intent -import android.content.Intent.ACTION_VIEW -import android.os.Bundle -import androidx.core.net.toUri -import androidx.preference.Preference -import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.GITHUB_URL -import com.zionhuang.music.ui.fragments.base.BaseSettingsFragment - -class AboutFragment : BaseSettingsFragment() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_about) - - findPreference(getString(R.string.pref_github))?.setOnPreferenceClickListener { - startActivity(Intent(ACTION_VIEW, GITHUB_URL.toUri())) - true - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/AppearanceSettingsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/settings/AppearanceSettingsFragment.kt deleted file mode 100644 index 634b9b96e..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/AppearanceSettingsFragment.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.zionhuang.music.ui.fragments.settings - -import android.os.Bundle -import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode -import androidx.preference.ListPreference -import androidx.preference.Preference -import androidx.preference.SwitchPreferenceCompat -import com.google.android.material.color.DynamicColors -import com.zionhuang.music.R -import com.zionhuang.music.ui.fragments.base.BaseSettingsFragment -import com.zionhuang.music.ui.fragments.dialogs.NavigationTabConfigDialog - -class AppearanceSettingsFragment : BaseSettingsFragment() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_appearance) - - val prefFollowSystemAccent = findPreference(getString(R.string.pref_follow_system_accent))!!.apply { - isVisible = DynamicColors.isDynamicColorAvailable() - setOnPreferenceChangeListener { _, _ -> - requireActivity().recreate() - true - } - } - findPreference(getString(R.string.pref_theme_color))?.apply { - isVisible = !DynamicColors.isDynamicColorAvailable() || (DynamicColors.isDynamicColorAvailable() && !prefFollowSystemAccent.isChecked) - setOnPreferenceChangeListener { _, _ -> - requireActivity().recreate() - true - } - } - findPreference(getString(R.string.pref_dark_theme))?.setOnPreferenceChangeListener { _, newValue -> - setDefaultNightMode((newValue as String).toInt()) - true - } - - findPreference(getString(R.string.pref_nav_tab_config))?.setOnPreferenceClickListener { - NavigationTabConfigDialog().show(childFragmentManager, null) - true - } - } -} diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/BackupRestoreSettingsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/settings/BackupRestoreSettingsFragment.kt deleted file mode 100644 index 0d9d9bde7..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/BackupRestoreSettingsFragment.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.zionhuang.music.ui.fragments.settings - -import android.content.Intent -import android.os.Bundle -import android.widget.Toast -import android.widget.Toast.LENGTH_SHORT -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import androidx.preference.Preference -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.R -import com.zionhuang.music.db.MusicDatabase -import com.zionhuang.music.db.MusicDatabase.Companion.DB_NAME -import com.zionhuang.music.db.checkpoint -import com.zionhuang.music.extensions.zipInputStream -import com.zionhuang.music.extensions.zipOutputStream -import com.zionhuang.music.playback.SongPlayer.Companion.PERSISTENT_QUEUE_FILE -import com.zionhuang.music.ui.activities.MainActivity -import com.zionhuang.music.ui.fragments.base.BaseSettingsFragment -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.util.zip.ZipEntry -import kotlin.system.exitProcess - - -class BackupRestoreSettingsFragment : BaseSettingsFragment() { - private val wantToBackup = mutableListOf(/* Preferences */ true, /* Database */ true) - private val wantToRestore = mutableListOf(/* Preferences */ true, /* Database */ true) - private val backupLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> - if (uri == null) return@registerForActivityResult - runCatching { - requireContext().applicationContext.contentResolver.openOutputStream(uri)?.buffered()?.zipOutputStream()?.use { outputStream -> - if (wantToBackup[0]) { // backup preferences - File(File(requireContext().filesDir.parentFile, "shared_prefs"), "${requireContext().packageName}_preferences.xml").inputStream().buffered().use { inputStream -> - outputStream.putNextEntry(ZipEntry(PREF_NAME)) - inputStream.copyTo(outputStream) - } - } - if (wantToBackup[1]) { // backup database - val database = MusicDatabase.getInstance(requireContext()) - database.checkpoint() - FileInputStream(database.openHelper.writableDatabase.path).use { inputStream -> - outputStream.putNextEntry(ZipEntry(DB_NAME)) - inputStream.copyTo(outputStream) - } - } - } - }.onSuccess { - Toast.makeText(requireContext(), R.string.message_backup_create_success, LENGTH_SHORT).show() - }.onFailure { - Toast.makeText(requireContext(), R.string.message_backup_create_failed, LENGTH_SHORT).show() - } - } - - private val restoreLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri == null) return@registerForActivityResult - runCatching { - requireContext().applicationContext.contentResolver.openInputStream(uri)?.zipInputStream()?.use { inputStream -> - var entry = inputStream.nextEntry - while (entry != null) { - when (entry.name) { - PREF_NAME -> if (wantToRestore[0]) { - File(File(requireContext().filesDir.parentFile, "shared_prefs"), "${requireContext().packageName}_preferences.xml").outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - } - DB_NAME -> if (wantToRestore[1]) { - val database = MusicDatabase.getInstance(requireContext()) - database.checkpoint() - database.close() - FileOutputStream(database.openHelper.writableDatabase.path).use { outputStream -> - inputStream.copyTo(outputStream) - } - } - } - entry = inputStream.nextEntry - } - } - requireContext().filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() - requireContext().startActivity(Intent(requireContext(), MainActivity::class.java)) - exitProcess(0) - }.onFailure { - Toast.makeText(requireContext(), R.string.message_restore_failed, LENGTH_SHORT).show() - } - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_backup_restore) - - findPreference(getString(R.string.pref_backup))?.setOnPreferenceClickListener { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.dialog_title_choose_backup_content) - .setMultiChoiceItems(arrayOf( - getString(R.string.choice_preferences), - getString(R.string.choice_database) - ), wantToBackup.toBooleanArray()) { dialog, which, isChecked -> - wantToBackup[which] = isChecked - if (dialog is AlertDialog) { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = !wantToBackup.all { !it } - } - } - .setPositiveButton(android.R.string.ok) { _, _ -> - backupLauncher.launch("${getString(R.string.app_name)}_${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))}.backup") - } - .setNegativeButton(android.R.string.cancel, null) - .show() - true - } - findPreference(getString(R.string.pref_restore))?.setOnPreferenceClickListener { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.dialog_title_choose_restore_content) - .setMultiChoiceItems(arrayOf( - getString(R.string.choice_preferences), - getString(R.string.choice_database) - ), wantToRestore.toBooleanArray()) { dialog, which, isChecked -> - wantToRestore[which] = isChecked - if (dialog is AlertDialog) { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = !wantToBackup.all { !it } - } - } - .setPositiveButton(android.R.string.ok) { _, _ -> - restoreLauncher.launch(arrayOf("application/octet-stream")) - } - .setNegativeButton(android.R.string.cancel, null) - .show() - true - } - } - - companion object { - const val PREF_NAME = "preferences.xml" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/ContentSettingsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/settings/ContentSettingsFragment.kt deleted file mode 100644 index cb1a65f36..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/ContentSettingsFragment.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.zionhuang.music.ui.fragments.settings - -import android.content.Intent -import android.content.SharedPreferences -import android.content.SharedPreferences.OnSharedPreferenceChangeListener -import android.os.Bundle -import android.view.View -import androidx.navigation.fragment.findNavController -import androidx.preference.EditTextPreference -import androidx.preference.ListPreference -import androidx.preference.Preference -import androidx.preference.SwitchPreferenceCompat -import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.ACCOUNT_EMAIL -import com.zionhuang.music.constants.Constants.ACCOUNT_NAME -import com.zionhuang.music.extensions.preferenceLiveData -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.ui.activities.MainActivity -import com.zionhuang.music.ui.fragments.base.BaseSettingsFragment -import kotlin.system.exitProcess - -class ContentSettingsFragment : BaseSettingsFragment(), OnSharedPreferenceChangeListener { - private lateinit var accountPreference: Preference - private lateinit var proxyTypePreference: ListPreference - private lateinit var proxyUrlPreference: EditTextPreference - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_content) - - accountPreference = findPreference(getString(R.string.pref_account))!!.apply { - title = sharedPreferences?.getString(ACCOUNT_NAME, null) ?: getString(R.string.login) - summary = sharedPreferences?.getString(ACCOUNT_EMAIL, null).orEmpty() - setOnPreferenceClickListener { - findNavController().navigate(R.id.webviewFragment) - true - } - } - val proxyEnabledPreference = findPreference(getString(R.string.pref_proxy_enabled))!! - proxyTypePreference = findPreference(getString(R.string.pref_proxy_type))!!.apply { - isVisible = proxyEnabledPreference.isChecked - } - proxyUrlPreference = findPreference(getString(R.string.pref_proxy_url))!!.apply { - isVisible = proxyEnabledPreference.isChecked - } - findPreference(getString(R.string.pref_restart))?.setOnPreferenceClickListener { - requireContext().startActivity(Intent(requireContext(), MainActivity::class.java)) - exitProcess(0) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - requireContext().preferenceLiveData(R.string.pref_proxy_enabled, false).observe(viewLifecycleOwner) { - proxyTypePreference.isVisible = it - proxyUrlPreference.isVisible = it - } - sharedPreferences.registerOnSharedPreferenceChangeListener(this) - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when (key) { - ACCOUNT_NAME -> accountPreference.title = sharedPreferences.getString(ACCOUNT_NAME, null) ?: getString(R.string.login) - ACCOUNT_EMAIL -> accountPreference.summary = sharedPreferences.getString(ACCOUNT_EMAIL, null).orEmpty() - } - } - - override fun onDestroy() { - super.onDestroy() - sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/GeneralSettingsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/settings/GeneralSettingsFragment.kt deleted file mode 100644 index 76dc46bd9..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/GeneralSettingsFragment.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zionhuang.music.ui.fragments.settings - -import android.os.Bundle -import com.zionhuang.music.R -import com.zionhuang.music.ui.fragments.base.BaseSettingsFragment - -class GeneralSettingsFragment : BaseSettingsFragment() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_general) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/PlayerAudioSettingsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/settings/PlayerAudioSettingsFragment.kt deleted file mode 100644 index 14e0ba117..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/PlayerAudioSettingsFragment.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.zionhuang.music.ui.fragments.settings - -import android.content.Intent -import android.media.audiofx.AudioEffect.* -import android.os.Bundle -import androidx.activity.result.contract.ActivityResultContracts -import androidx.preference.Preference -import com.zionhuang.music.R -import com.zionhuang.music.playback.MediaSessionConnection -import com.zionhuang.music.ui.fragments.base.BaseSettingsFragment - -class PlayerAudioSettingsFragment : BaseSettingsFragment() { - private val activityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {} - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_player_audio) - - val equalizerIntent = Intent(ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { - putExtra(EXTRA_AUDIO_SESSION, MediaSessionConnection.binder?.songPlayer?.player?.audioSessionId) - putExtra(EXTRA_PACKAGE_NAME, requireContext().packageName) - putExtra(EXTRA_CONTENT_TYPE, CONTENT_TYPE_MUSIC) - } - findPreference(getString(R.string.pref_equalizer))?.apply { - isEnabled = equalizerIntent.resolveActivity(requireContext().packageManager) != null - setOnPreferenceClickListener { - activityResultLauncher.launch(equalizerIntent) - true - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/PrivacySettingsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/settings/PrivacySettingsFragment.kt deleted file mode 100644 index 206e2f937..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/PrivacySettingsFragment.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.zionhuang.music.ui.fragments.settings - -import android.os.Bundle -import androidx.preference.Preference -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.R -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.fragments.base.BaseSettingsFragment -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -class PrivacySettingsFragment : BaseSettingsFragment() { - @OptIn(DelicateCoroutinesApi::class) - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_privacy) - - findPreference(getString(R.string.pref_clear_search_history))?.setOnPreferenceClickListener { - MaterialAlertDialogBuilder(requireContext()) - .setMessage(R.string.clear_search_history_question) - .setPositiveButton(android.R.string.ok) { _, _ -> - GlobalScope.launch { - SongRepository(requireContext()).clearSearchHistory() - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - true - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/StorageSettingsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/settings/StorageSettingsFragment.kt deleted file mode 100644 index 3463032f6..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/StorageSettingsFragment.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.zionhuang.music.ui.fragments.settings - -import android.content.ComponentName -import android.content.Context.BIND_AUTO_CREATE -import android.content.Intent -import android.content.Intent.ACTION_VIEW -import android.content.ServiceConnection -import android.os.Bundle -import android.os.IBinder -import android.text.format.Formatter -import androidx.core.net.toUri -import androidx.preference.NeoSeekBarPreference -import androidx.preference.Preference -import coil.annotation.ExperimentalCoilApi -import coil.imageLoader -import com.zionhuang.music.R -import com.zionhuang.music.extensions.tryOrNull -import com.zionhuang.music.playback.MediaSessionConnection -import com.zionhuang.music.playback.MusicService -import com.zionhuang.music.ui.fragments.base.BaseSettingsFragment - -class StorageSettingsFragment : BaseSettingsFragment() { - private val serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName, iBinder: IBinder) { - if (iBinder !is MusicService.MusicBinder) return - findPreference(getString(R.string.pref_song_max_cache_size))?.apply { - MediaSessionConnection.binder?.cache?.let { cache -> - tryOrNull { cache.cacheSpace }?.let { used -> - summary = getString(R.string.size_used, Formatter.formatShortFileSize(context, used)) - } - } - } - } - - override fun onServiceDisconnected(name: ComponentName) {} - } - - @OptIn(ExperimentalCoilApi::class) - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_storage) - - findPreference(getString(R.string.pref_open_saf))?.setOnPreferenceClickListener { - try { - startActivity(Intent(ACTION_VIEW, "content://${requireContext().packageName}.provider/root/root".toUri()).apply { - setPackage("com.google.android.documentsui") - setClassName("com.google.android.documentsui", "com.android.documentsui.files.FilesActivity") - }) - } catch (e: Exception) { - e.printStackTrace() - } - true - } - val maxImageCacheSizePreference = findPreference(getString(R.string.pref_image_max_cache_size))!!.apply { - setLabelFormatter { - VALUE_TO_SIZE_TEXT[it] - } - context.imageLoader.diskCache?.let { diskCache -> - summary = getString(R.string.size_used, Formatter.formatShortFileSize(context, diskCache.size)) - } - } - findPreference(getString(R.string.pref_clear_image_cache))?.apply { - context.imageLoader.diskCache?.let { diskCache -> - setOnPreferenceClickListener { - diskCache.clear() - maxImageCacheSizePreference.summary = getString(R.string.size_used, Formatter.formatShortFileSize(context, diskCache.size)) - true - } - } - - } - findPreference(getString(R.string.pref_song_max_cache_size))?.apply { - setLabelFormatter { - VALUE_TO_SIZE_TEXT[it] - } - } - - requireContext().bindService(Intent(requireContext(), MusicService::class.java), serviceConnection, BIND_AUTO_CREATE) - } - - override fun onDestroy() { - requireContext().unbindService(serviceConnection) - super.onDestroy() - } - - companion object { - val VALUE_TO_SIZE_TEXT = listOf("128MB", "256MB", "512MB", "1GB", "2GB", "4GB", "8GB", "∞") - val VALUE_TO_MB = listOf(128, 256, 512, 1024, 2048, 4096, 8192, -1) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/songs/ArtistSongsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/songs/ArtistSongsFragment.kt deleted file mode 100644 index 76076c0d5..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/songs/ArtistSongsFragment.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.zionhuang.music.ui.fragments.songs - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.music.R -import com.zionhuang.music.db.entities.ArtistEntity -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.extensions.requireAppCompatActivity -import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.adapters.LocalItemAdapter -import com.zionhuang.music.ui.adapters.selection.LocalItemDetailsLookup -import com.zionhuang.music.ui.adapters.selection.LocalItemKeyProvider -import com.zionhuang.music.ui.fragments.base.RecyclerViewFragment -import com.zionhuang.music.ui.listeners.SongMenuListener -import com.zionhuang.music.utils.addActionModeObserver -import com.zionhuang.music.viewmodels.PlaybackViewModel -import com.zionhuang.music.viewmodels.SongsViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class ArtistSongsFragment : RecyclerViewFragment() { - private val args: ArtistSongsFragmentArgs by navArgs() - private lateinit var artist: ArtistEntity - - private val playbackViewModel by activityViewModels() - private val songsViewModel by activityViewModels() - private val menuListener = SongMenuListener(this) - override val adapter = LocalItemAdapter().apply { - songMenuListener = menuListener - } - private var tracker: SelectionTracker? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - - binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) - binding.recyclerView.addOnClickListener { position, _ -> - if (adapter.currentList[position] !is Song) return@addOnClickListener - playbackViewModel.playQueue(requireActivity(), - ListQueue( - title = artist.name, - items = adapter.currentList.drop(1).filterIsInstance().map { it.toMediaItem() }, - startIndex = position - 1 - ) - ) - } - adapter.onShuffle = { - playbackViewModel.playQueue(requireActivity(), - ListQueue( - title = artist.name, - items = adapter.currentList.drop(1).filterIsInstance().shuffled().map { it.toMediaItem() } - ) - ) - } - - tracker = SelectionTracker.Builder("selectionId", binding.recyclerView, LocalItemKeyProvider(adapter), LocalItemDetailsLookup(binding.recyclerView), StorageStrategy.createStringStorage()) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - adapter.tracker = this - addActionModeObserver(requireActivity(), R.menu.song_batch) { item -> - val map = adapter.currentList.associateBy { it.id } - val songs = selection.toList().map { map[it] }.filterIsInstance() - when (item.itemId) { - R.id.action_play_next -> menuListener.playNext(songs) - R.id.action_add_to_queue -> menuListener.addToQueue(songs) - R.id.action_add_to_playlist -> menuListener.addToPlaylist(songs) - R.id.action_download -> menuListener.download(songs) - R.id.action_remove_download -> menuListener.removeDownload(songs) - R.id.action_refetch -> menuListener.refetch(songs) - R.id.action_delete -> menuListener.delete(songs) - } - true - } - } - - lifecycleScope.launch { - artist = SongRepository(requireContext()).getArtistById(args.artistId)!! - requireAppCompatActivity().title = artist.name - songsViewModel.getArtistSongsAsFlow(args.artistId).collectLatest { - adapter.submitList(it) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/songs/CustomPlaylistFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/songs/CustomPlaylistFragment.kt deleted file mode 100644 index 2238ad89c..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/songs/CustomPlaylistFragment.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.zionhuang.music.ui.fragments.songs - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.DOWNLOADED_PLAYLIST_ID -import com.zionhuang.music.constants.Constants.LIKED_PLAYLIST_ID -import com.zionhuang.music.db.entities.LocalItem -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.extensions.requireAppCompatActivity -import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.ui.adapters.LocalItemAdapter -import com.zionhuang.music.ui.adapters.selection.LocalItemDetailsLookup -import com.zionhuang.music.ui.adapters.selection.LocalItemKeyProvider -import com.zionhuang.music.ui.fragments.base.RecyclerViewFragment -import com.zionhuang.music.ui.listeners.SongMenuListener -import com.zionhuang.music.utils.addActionModeObserver -import com.zionhuang.music.viewmodels.PlaybackViewModel -import com.zionhuang.music.viewmodels.SongsViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class CustomPlaylistFragment : RecyclerViewFragment() { - private val args: PlaylistSongsFragmentArgs by navArgs() - private val playlistId by lazy { args.playlistId } - - private val playbackViewModel by activityViewModels() - private val songsViewModel by activityViewModels() - private val menuListener = SongMenuListener(this) - override val adapter = LocalItemAdapter().apply { - songMenuListener = menuListener - } - private var tracker: SelectionTracker? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - - binding.recyclerView.apply { - layoutManager = LinearLayoutManager(requireContext()) - setHasFixedSize(true) - } - binding.recyclerView.addOnClickListener { position, _ -> - if (adapter.currentList[position] !is LocalItem) return@addOnClickListener - - playbackViewModel.playQueue(requireActivity(), ListQueue( - title = when (playlistId) { - LIKED_PLAYLIST_ID -> getString(R.string.liked_songs) - DOWNLOADED_PLAYLIST_ID -> getString(R.string.downloaded_songs) - else -> throw IllegalArgumentException("Unknown custom playlist") - }, - items = adapter.currentList.filterIsInstance().map { it.toMediaItem() }, - startIndex = position - 1 - )) - } - adapter.onShuffle = { - playbackViewModel.playQueue(requireActivity(), ListQueue( - title = when (playlistId) { - LIKED_PLAYLIST_ID -> getString(R.string.liked_songs) - DOWNLOADED_PLAYLIST_ID -> getString(R.string.downloaded_songs) - else -> throw IllegalArgumentException("Unknown custom playlist") - }, - items = adapter.currentList.filterIsInstance().shuffled().map { it.toMediaItem() } - )) - } - - tracker = SelectionTracker.Builder("selectionId", binding.recyclerView, LocalItemKeyProvider(adapter), LocalItemDetailsLookup(binding.recyclerView), StorageStrategy.createStringStorage()) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - adapter.tracker = this - addActionModeObserver(requireActivity(), R.menu.song_batch) { item -> - val map = adapter.currentList.associateBy { it.id } - val songs = selection.toList().map { map[it] }.filterIsInstance() - when (item.itemId) { - R.id.action_play_next -> menuListener.playNext(songs) - R.id.action_add_to_queue -> menuListener.addToQueue(songs) - R.id.action_add_to_playlist -> menuListener.addToPlaylist(songs) - R.id.action_download -> menuListener.download(songs) - R.id.action_remove_download -> menuListener.removeDownload(songs) - R.id.action_refetch -> menuListener.refetch(songs) - R.id.action_delete -> menuListener.delete(songs) - } - true - } - } - - lifecycleScope.launch { - requireAppCompatActivity().title = when (playlistId) { - LIKED_PLAYLIST_ID -> getString(R.string.liked_songs) - DOWNLOADED_PLAYLIST_ID -> getString(R.string.downloaded_songs) - else -> throw IllegalArgumentException("Unknown custom playlist") - } - when (playlistId) { - LIKED_PLAYLIST_ID -> songsViewModel.getLikedSongsAsFlow() - DOWNLOADED_PLAYLIST_ID -> songsViewModel.getDownloadedSongsAsFlow() - else -> throw IllegalArgumentException("Unknown custom playlist") - }.collectLatest { - adapter.submitList(it) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/songs/PlaylistSongsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/songs/PlaylistSongsFragment.kt deleted file mode 100644 index d478c3b48..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/songs/PlaylistSongsFragment.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.zionhuang.music.ui.fragments.songs - -import android.graphics.Canvas -import android.os.Bundle -import android.view.MotionEvent -import android.view.View -import androidx.core.view.ViewCompat -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.ItemTouchHelper.* -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.music.R -import com.zionhuang.music.db.entities.LocalItem -import com.zionhuang.music.db.entities.Playlist -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.extensions.requireAppCompatActivity -import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.adapters.DraggableLocalItemAdapter -import com.zionhuang.music.ui.adapters.selection.DraggableLocalItemKeyProvider -import com.zionhuang.music.ui.fragments.base.RecyclerViewFragment -import com.zionhuang.music.ui.listeners.SongMenuListener -import com.zionhuang.music.ui.viewholders.LocalItemViewHolder -import com.zionhuang.music.ui.viewholders.SongViewHolder -import com.zionhuang.music.utils.addActionModeObserver -import com.zionhuang.music.viewmodels.PlaybackViewModel -import com.zionhuang.music.viewmodels.SongsViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class PlaylistSongsFragment : RecyclerViewFragment() { - private val args: PlaylistSongsFragmentArgs by navArgs() - private val playlistId by lazy { args.playlistId } - private lateinit var playlist: Playlist - - private val playbackViewModel by activityViewModels() - private val songsViewModel by activityViewModels() - private val songRepository by lazy { SongRepository(requireContext()) } - private val menuListener = SongMenuListener(this) - override val adapter = DraggableLocalItemAdapter().apply { - songMenuListener = menuListener - isDraggable = true - } - private var tracker: SelectionTracker? = null - - private var move: Pair? = null - private val itemTouchHelper = ItemTouchHelper(object : SimpleCallback(UP or DOWN, LEFT or RIGHT) { - private val elevation by lazy { requireContext().resources.getDimension(R.dimen.drag_item_elevation) } - - override fun isLongPressDragEnabled(): Boolean = false - - override fun canDropOver(recyclerView: RecyclerView, current: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder) = - current is SongViewHolder && target is SongViewHolder - - override fun onChildDraw(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) { - super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) - if (isCurrentlyActive) { - ViewCompat.setElevation(viewHolder.itemView, elevation) - } - } - - override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - super.clearView(recyclerView, viewHolder) - ViewCompat.setElevation(viewHolder.itemView, 0f) - lifecycleScope.launch { - move?.let { - songRepository.movePlaylistItems(playlistId, it.first, it.second) - move = null - } - } - } - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - val from = viewHolder.absoluteAdapterPosition - 1 - val to = target.absoluteAdapterPosition - 1 - adapter.notifyItemMoved(from + 1, to + 1) - move = Pair(move?.first ?: from, to) - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val position = viewHolder.absoluteAdapterPosition - 1 - lifecycleScope.launch { - songRepository.removeSongFromPlaylist(playlistId, position) - } - } - }) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - - binding.recyclerView.apply { - layoutManager = LinearLayoutManager(requireContext()) - setHasFixedSize(true) - itemTouchHelper.attachToRecyclerView(this) - } - binding.recyclerView.addOnClickListener { position, _ -> - if (adapter.currentList[position] !is LocalItem) return@addOnClickListener - - playbackViewModel.playQueue(requireActivity(), ListQueue( - title = playlist.playlist.name, - items = adapter.currentList.filterIsInstance().map { it.toMediaItem() }, - startIndex = position - 1 - )) - } - adapter.itemTouchHelper = itemTouchHelper - adapter.onShuffle = { - playbackViewModel.playQueue(requireActivity(), ListQueue( - title = playlist.playlist.name, - items = adapter.currentList.filterIsInstance().shuffled().map { it.toMediaItem() } - )) - } - - tracker = SelectionTracker.Builder("selectionId", binding.recyclerView, DraggableLocalItemKeyProvider(adapter), - object : ItemDetailsLookup() { - // Disable selection if dragging - override fun getItemDetails(e: MotionEvent): ItemDetails? = if (move != null) null else binding.recyclerView.findChildViewUnder(e.x, e.y)?.let { v -> - (binding.recyclerView.getChildViewHolder(v) as? LocalItemViewHolder)?.itemDetails - } - }, StorageStrategy.createStringStorage()) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - adapter.tracker = this - addActionModeObserver(requireActivity(), R.menu.song_batch) { item -> - val map = adapter.currentList.associateBy { it.id } - val songs = selection.toList().map { map[it] }.filterIsInstance() - when (item.itemId) { - R.id.action_play_next -> menuListener.playNext(songs) - R.id.action_add_to_queue -> menuListener.addToQueue(songs) - R.id.action_add_to_playlist -> menuListener.addToPlaylist(songs) - R.id.action_download -> menuListener.download(songs) - R.id.action_remove_download -> menuListener.removeDownload(songs) - R.id.action_refetch -> menuListener.refetch(songs) - R.id.action_delete -> menuListener.delete(songs) - } - true - } - } - - lifecycleScope.launch { - playlist = songRepository.getPlaylistById(playlistId) - requireAppCompatActivity().title = playlist.playlist.name - songsViewModel.getPlaylistSongsAsFlow(playlistId).collectLatest { - adapter.submitList(it, animation = false) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/youtube/YouTubeBrowseFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/youtube/YouTubeBrowseFragment.kt deleted file mode 100644 index a7cfa0849..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/youtube/YouTubeBrowseFragment.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.zionhuang.music.ui.fragments.youtube - -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.core.view.MenuProvider -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.innertube.models.SongItem -import com.zionhuang.music.R -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.extensions.requireAppCompatActivity -import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.playback.MediaSessionConnection -import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.ui.adapters.YouTubeItemPagingAdapter -import com.zionhuang.music.ui.adapters.selection.YouTubeItemDetailsLookup -import com.zionhuang.music.ui.adapters.selection.YouTubeItemKeyProvider -import com.zionhuang.music.ui.fragments.base.PagingRecyclerViewFragment -import com.zionhuang.music.ui.listeners.YTItemBatchMenuListener -import com.zionhuang.music.utils.NavigationEndpointHandler -import com.zionhuang.music.utils.addActionModeObserver -import com.zionhuang.music.viewmodels.YouTubeBrowseViewModel -import com.zionhuang.music.viewmodels.YouTubeBrowseViewModelFactory -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class YouTubeBrowseFragment : PagingRecyclerViewFragment(), MenuProvider { - private val args: YouTubeBrowseFragmentArgs by navArgs() - private val viewModel by viewModels { YouTubeBrowseViewModelFactory(requireActivity().application, args.endpoint) } - - override val adapter = YouTubeItemPagingAdapter(NavigationEndpointHandler(this)) - private var tracker: SelectionTracker? = null - private val menuListener = YTItemBatchMenuListener(this) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) - - tracker = SelectionTracker.Builder("selectionId", binding.recyclerView, YouTubeItemKeyProvider(adapter), YouTubeItemDetailsLookup(binding.recyclerView), StorageStrategy.createStringStorage()) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - adapter.tracker = this - addObserver(object : SelectionTracker.SelectionObserver() { - override fun onItemStateChanged(key: String, selected: Boolean) { - getSwipeRefreshLayout().isEnabled = !hasSelection() - } - }) - addActionModeObserver(requireActivity(), R.menu.youtube_item_batch) { menuItem -> - val map = adapter.snapshot().items.associateBy { it.id } - val items = selection.toList().map { map[it] }.filterIsInstance() - when (menuItem.itemId) { - R.id.action_play_next -> menuListener.playNext(items) - R.id.action_add_to_queue -> menuListener.addToQueue(items) - R.id.action_add_to_library -> menuListener.addToLibrary(items) - R.id.action_add_to_playlist -> menuListener.addToPlaylist(items) - R.id.action_download -> menuListener.download(items) - } - true - } - } - - if (args.endpoint.isAlbumEndpoint) { - adapter.onPlayAlbum = { - viewModel.albumSongs?.let { songs -> - MediaSessionConnection.binder?.songPlayer?.playQueue(ListQueue( - title = viewModel.albumName, - items = songs.map { it.toMediaItem() } - )) - } - } - adapter.onShuffleAlbum = { - viewModel.albumSongs?.let { songs -> - MediaSessionConnection.binder?.songPlayer?.playQueue(ListQueue( - title = viewModel.albumName, - items = songs.shuffled().map { it.toMediaItem() } - )) - } - } - binding.recyclerView.addOnClickListener { position, _ -> - (adapter.getItemAt(position) as? SongItem)?.let { item -> - viewModel.albumSongs?.let { songs -> - MediaSessionConnection.binder?.songPlayer?.playQueue(ListQueue( - title = viewModel.albumName, - items = songs.map { it.toMediaItem() }, - startIndex = songs.indexOfFirst { it.id == item.id } - )) - } - } - } - } - lifecycleScope.launch { - viewModel.pagingData.collectLatest { - adapter.submitData(it) - } - } - - requireAppCompatActivity().title = "" - requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - } - - override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.search_and_settings, menu) - menu.findItem(R.id.action_search).actionView = null - } - - override fun onMenuItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_search -> findNavController().navigate(R.id.youtubeSuggestionFragment) - R.id.action_settings -> findNavController().navigate(R.id.settingsActivity) - } - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/youtube/YouTubeSearchFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/youtube/YouTubeSearchFragment.kt deleted file mode 100644 index b548e5b43..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/youtube/YouTubeSearchFragment.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.zionhuang.music.ui.fragments.youtube - -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.appcompat.widget.Toolbar -import androidx.core.view.MenuProvider -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import androidx.paging.LoadState -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ALBUM -import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ARTIST -import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_COMMUNITY_PLAYLIST -import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_FEATURED_PLAYLIST -import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_SONG -import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_VIDEO -import com.zionhuang.innertube.models.YTItem -import com.zionhuang.music.R -import com.zionhuang.music.databinding.FragmentSearchBinding -import com.zionhuang.music.extensions.requireAppCompatActivity -import com.zionhuang.music.ui.adapters.YouTubeItemPagingAdapter -import com.zionhuang.music.ui.adapters.selection.YouTubeItemDetailsLookup -import com.zionhuang.music.ui.adapters.selection.YouTubeItemKeyProvider -import com.zionhuang.music.ui.fragments.base.AbsPagingRecyclerViewFragment -import com.zionhuang.music.ui.listeners.YTItemBatchMenuListener -import com.zionhuang.music.utils.NavigationEndpointHandler -import com.zionhuang.music.utils.addActionModeObserver -import com.zionhuang.music.viewmodels.YouTubeSearchViewModel -import com.zionhuang.music.viewmodels.YouTubeSearchViewModelFactory -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.launch - -class YouTubeSearchFragment : AbsPagingRecyclerViewFragment(), MenuProvider { - override fun getViewBinding() = FragmentSearchBinding.inflate(layoutInflater) - override fun getToolbar(): Toolbar = binding.toolbar - override fun getRecyclerView(): RecyclerView = binding.recyclerView - override fun getLayoutLoadState() = binding.layoutLoadState - override fun getSwipeRefreshLayout() = binding.swipeRefresh - - private val args: YouTubeSearchFragmentArgs by navArgs() - - private val viewModel by viewModels { YouTubeSearchViewModelFactory(requireActivity().application, args.query) } - - private val navigationEndpointHandler = NavigationEndpointHandler(this) - override val adapter = YouTubeItemPagingAdapter(navigationEndpointHandler) - private var tracker: SelectionTracker? = null - private val menuListener = YTItemBatchMenuListener(this) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - enterTransition = null - exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()).addTarget(R.id.fragment_content).addTarget(R.id.fragment_content) - - requireAppCompatActivity().title = args.query - - binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) - - when (viewModel.filter.value) { - null -> binding.chipAll - FILTER_SONG -> binding.chipSongs - FILTER_VIDEO -> binding.chipVideos - FILTER_ALBUM -> binding.chipAlbums - FILTER_ARTIST -> binding.chipArtists - FILTER_COMMUNITY_PLAYLIST -> binding.chipCommunityPlaylists - FILTER_FEATURED_PLAYLIST -> binding.chipFeaturedPlaylists - else -> null - }?.isChecked = true - - binding.chipGroup.setOnCheckedStateChangeListener { group, _ -> - val newFilter = when (group.checkedChipId) { - R.id.chip_all -> null - R.id.chip_songs -> FILTER_SONG - R.id.chip_videos -> FILTER_VIDEO - R.id.chip_albums -> FILTER_ALBUM - R.id.chip_artists -> FILTER_ARTIST - R.id.chip_community_playlists -> FILTER_COMMUNITY_PLAYLIST - R.id.chip_featured_playlists -> FILTER_FEATURED_PLAYLIST - else -> null - } - if (viewModel.filter.value == newFilter) { - binding.recyclerView.smoothScrollToPosition(0) - } else { - tracker?.clearSelection() - viewModel.filter.value = newFilter - adapter.refresh() - } - } - - tracker = SelectionTracker.Builder("selectionId", binding.recyclerView, YouTubeItemKeyProvider(adapter), YouTubeItemDetailsLookup(binding.recyclerView), StorageStrategy.createStringStorage()) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - adapter.tracker = this - addObserver(object : SelectionTracker.SelectionObserver() { - override fun onItemStateChanged(key: String, selected: Boolean) { - getSwipeRefreshLayout().isEnabled = !hasSelection() - } - }) - addActionModeObserver(requireActivity(), R.menu.youtube_item_batch) { menuItem -> - val map = adapter.snapshot().items.associateBy { it.id } - val items = selection.toList().map { map[it] }.filterIsInstance() - when (menuItem.itemId) { - R.id.action_play_next -> menuListener.playNext(items) - R.id.action_add_to_queue -> menuListener.addToQueue(items) - R.id.action_add_to_library -> menuListener.addToLibrary(items) - R.id.action_add_to_playlist -> menuListener.addToPlaylist(items) - R.id.action_download -> menuListener.download(items) - } - true - } - } - - lifecycleScope.launch { - // Always show the first item when switching filters - adapter.loadStateFlow - .distinctUntilChangedBy { it.refresh } - .filter { it.refresh is LoadState.NotLoading } - .collectLatest { - binding.recyclerView.scrollToPosition(0) - } - } - - lifecycleScope.launch { - viewModel.pagingData.collectLatest { - adapter.submitData(it) - } - } - - requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - } - - override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.search_icon, menu) - } - - override fun onMenuItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.action_search) { - findNavController().navigate(YouTubeSearchFragmentDirections.actionSearchResultToSearchSuggestion(args.query)) - } - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/youtube/YouTubeSuggestionFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/youtube/YouTubeSuggestionFragment.kt deleted file mode 100644 index 46f8af454..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/youtube/YouTubeSuggestionFragment.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.zionhuang.music.ui.fragments.youtube - -import android.app.Activity.RESULT_OK -import android.content.ActivityNotFoundException -import android.content.Intent -import android.os.Bundle -import android.speech.RecognizerIntent -import android.speech.RecognizerIntent.ACTION_RECOGNIZE_SPEECH -import android.view.KeyEvent.ACTION_DOWN -import android.view.KeyEvent.KEYCODE_ENTER -import android.view.View -import android.view.inputmethod.EditorInfo.IME_ACTION_PREVIOUS -import android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.widget.Toolbar -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.music.R -import com.zionhuang.music.databinding.FragmentYoutubeSuggestionBinding -import com.zionhuang.music.extensions.getTextChangeFlow -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.extensions.systemBarInsetsCompat -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.adapters.YouTubeItemAdapter -import com.zionhuang.music.ui.fragments.base.NavigationFragment -import com.zionhuang.music.utils.KeyboardUtil.hideKeyboard -import com.zionhuang.music.utils.KeyboardUtil.showKeyboard -import com.zionhuang.music.utils.NavigationEndpointHandler -import com.zionhuang.music.viewmodels.SuggestionViewModel -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.launch - -class YouTubeSuggestionFragment : NavigationFragment() { - override fun getViewBinding() = FragmentYoutubeSuggestionBinding.inflate(layoutInflater) - override fun getToolbar(): Toolbar = binding.toolbar - - private val args: YouTubeSuggestionFragmentArgs by navArgs() - - private val viewModel by viewModels() - private val adapter = YouTubeItemAdapter(NavigationEndpointHandler(this)) - - private val voiceResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - val spokenText = it.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull() - if (spokenText != null) { - binding.searchView.setText(spokenText) - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - adapter.onFillQuery = { query -> - binding.searchView.setText(query) - binding.searchView.setSelection(query.length) - } - adapter.onSearch = this::search - adapter.onRefreshSuggestions = { - viewModel.fetchSuggestions(binding.searchView.text.toString()) - } - binding.recyclerView.apply { - setHasFixedSize(true) - layoutManager = LinearLayoutManager(requireContext()) - adapter = this@YouTubeSuggestionFragment.adapter - setOnApplyWindowInsetsListener { v, insets -> - v.updatePadding(bottom = insets.systemBarInsetsCompat.bottom) - insets - } - } - binding.btnVoice.setOnClickListener { - try { - voiceResultLauncher.launch(Intent(ACTION_RECOGNIZE_SPEECH)) - } catch (_: ActivityNotFoundException) { - } - } - setupSearchView() - showKeyboard() - args.query?.let { query -> - binding.searchView.setText(query) - binding.searchView.setSelection(query.length) - } - viewModel.suggestions.observe(viewLifecycleOwner) { dataSet -> - adapter.submitList(dataSet) - } - } - - @OptIn(FlowPreview::class) - private fun setupSearchView() { - lifecycleScope.launch { - binding.searchView - .getTextChangeFlow() - .debounce(100L) - .collectLatest { - viewModel.fetchSuggestions(it) - binding.btnClear.isVisible = it.isNotEmpty() - } - } - binding.searchView.setOnEditorActionListener { view, actionId, event -> - if (actionId == IME_ACTION_PREVIOUS) { - hideKeyboard() - true - } else if ((event?.keyCode == KEYCODE_ENTER && event.action == ACTION_DOWN) || event?.action == IME_ACTION_SEARCH) { - hideKeyboard() - search(view.text.toString()) - true - } else { - false - } - } - binding.btnClear.setOnClickListener { - binding.searchView.text.clear() - } - } - - @OptIn(DelicateCoroutinesApi::class) - private fun search(query: String) { - if (!sharedPreferences.getBoolean(getString(R.string.pref_pause_search_history), false)) { - GlobalScope.launch { - SongRepository(requireContext()).insertSearchHistory(query) - } - } - exitTransition = null - val action = YouTubeSuggestionFragmentDirections.actionSearchSuggestionToSearchResult(query) - findNavController().navigate(action) - } - - override fun onPause() { - super.onPause() - hideKeyboard() - } - - private fun showKeyboard() = showKeyboard(requireActivity(), binding.searchView) - private fun hideKeyboard() = hideKeyboard(requireActivity(), binding.searchView) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/listeners/AlbumMenuListener.kt b/app/src/main/java/com/zionhuang/music/ui/listeners/AlbumMenuListener.kt deleted file mode 100644 index 36173b6f9..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/listeners/AlbumMenuListener.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.zionhuang.music.ui.listeners - -import android.content.Intent -import androidx.fragment.app.Fragment -import com.zionhuang.innertube.models.BrowseEndpoint.Companion.artistBrowseEndpoint -import com.zionhuang.music.R -import com.zionhuang.music.db.entities.Album -import com.zionhuang.music.extensions.exceptionHandler -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.toMediaMetadata -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.utils.NavigationEndpointHandler -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -interface IAlbumMenuListener { - fun playNext(albums: List) - fun addToQueue(albums: List) - fun addToPlaylist(albums: List) - fun viewArtist(album: Album) - fun share(album: Album) - fun refetch(albums: List) - fun delete(albums: List) - - fun playNext(album: Album) = playNext(listOf(album)) - fun addToQueue(album: Album) = addToQueue(listOf(album)) - fun addToPlaylist(album: Album) = addToPlaylist(listOf(album)) - fun refetch(album: Album) = refetch(listOf(album)) - fun delete(album: Album) = delete(listOf(album)) -} - -class AlbumMenuListener(override val fragment: Fragment) : BaseMenuListener(fragment), IAlbumMenuListener { - private val songRepository by lazy { SongRepository(fragment.requireContext()) } - - override suspend fun getMediaMetadata(items: List): List = items.flatMap { album -> - songRepository.getAlbumSongs(album.id) - }.map { - it.toMediaMetadata() - } - - override fun playNext(albums: List) { - playNext(albums, context.resources.getQuantityString(R.plurals.snackbar_album_play_next, albums.size, albums.size)) - } - - override fun addToQueue(albums: List) { - addToQueue(albums, context.resources.getQuantityString(R.plurals.snackbar_album_added_to_queue, albums.size, albums.size)) - } - - override fun addToPlaylist(albums: List) { - addToPlaylist { playlist -> - songRepository.addToPlaylist(playlist, albums) - } - } - - override fun viewArtist(album: Album) { - if (album.artists.isNotEmpty()) { - NavigationEndpointHandler(fragment).handle(artistBrowseEndpoint(album.artists[0].id)) - } - } - - override fun share(album: Album) { - val intent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/browse/${album.id}") - } - fragment.startActivity(Intent.createChooser(intent, null)) - } - - @OptIn(DelicateCoroutinesApi::class) - override fun refetch(albums: List) { - GlobalScope.launch(context.exceptionHandler) { - songRepository.refetchAlbums(albums.map { it.album }) - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun delete(albums: List) { - GlobalScope.launch(context.exceptionHandler) { - songRepository.deleteAlbums(albums) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/listeners/ArtistMenuListener.kt b/app/src/main/java/com/zionhuang/music/ui/listeners/ArtistMenuListener.kt deleted file mode 100644 index 0f6d113ca..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/listeners/ArtistMenuListener.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.zionhuang.music.ui.listeners - -import android.content.Intent -import android.content.Intent.ACTION_SEND -import android.content.Intent.EXTRA_TEXT -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_ARTIST -import com.zionhuang.music.db.entities.Artist -import com.zionhuang.music.extensions.exceptionHandler -import com.zionhuang.music.extensions.show -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.sortInfo.SongSortInfoPreference -import com.zionhuang.music.models.toMediaMetadata -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.fragments.dialogs.EditArtistDialog -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -interface IArtistMenuListener { - fun edit(artist: Artist) - fun playNext(artists: List) - fun addToQueue(artists: List) - fun addToPlaylist(artists: List) - fun share(artist: Artist) - fun refetch(artists: List) - - fun playNext(artist: Artist) = playNext(listOf(artist)) - fun addToQueue(artist: Artist) = addToQueue(listOf(artist)) - fun addToPlaylist(artist: Artist) = addToPlaylist(listOf(artist)) - fun refetch(artist: Artist) = refetch(listOf(artist)) -} - -class ArtistMenuListener(override val fragment: Fragment) : BaseMenuListener(fragment), IArtistMenuListener { - private val songRepository by lazy { SongRepository(fragment.requireContext()) } - - override suspend fun getMediaMetadata(items: List): List = items.flatMap { artist -> - songRepository.getArtistSongs(artist.id, SongSortInfoPreference).getList() - }.map { - it.toMediaMetadata() - } - - override fun edit(artist: Artist) { - EditArtistDialog().apply { - arguments = bundleOf(EXTRA_ARTIST to artist) - }.show(context) - } - - override fun playNext(artists: List) { - playNext(artists, context.resources.getQuantityString(R.plurals.snackbar_artist_play_next, artists.size, artists.size)) - } - - override fun addToQueue(artists: List) { - addToQueue(artists, context.resources.getQuantityString(R.plurals.snackbar_artist_added_to_queue, artists.size, artists.size)) - } - - override fun addToPlaylist(artists: List) { - addToPlaylist { playlist -> - songRepository.addToPlaylist(playlist, artists) - } - } - - override fun share(artist: Artist) { - if (artist.artist.isYouTubeArtist) { - val intent = Intent().apply { - action = ACTION_SEND - type = "text/plain" - putExtra(EXTRA_TEXT, "https://music.youtube.com/channel/${artist.id}") - } - fragment.startActivity(Intent.createChooser(intent, null)) - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun refetch(artists: List) { - GlobalScope.launch(context.exceptionHandler) { - songRepository.refetchArtists(artists.map { it.artist }) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/listeners/BaseMenuListener.kt b/app/src/main/java/com/zionhuang/music/ui/listeners/BaseMenuListener.kt deleted file mode 100644 index 6eea9c046..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/listeners/BaseMenuListener.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.zionhuang.music.ui.listeners - -import android.content.Context -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_SHORT -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_MEDIA_METADATA_ITEMS -import com.zionhuang.music.constants.MediaSessionConstants.COMMAND_ADD_TO_QUEUE -import com.zionhuang.music.constants.MediaSessionConstants.COMMAND_PLAY_NEXT -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.extensions.exceptionHandler -import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.playback.MediaSessionConnection -import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.ui.activities.MainActivity -import com.zionhuang.music.ui.fragments.dialogs.ChoosePlaylistDialog -import com.zionhuang.music.ui.fragments.songs.PlaylistSongsFragmentArgs -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -abstract class BaseMenuListener(open val fragment: Fragment) { - val context: Context get() = fragment.requireContext() - val mainActivity: MainActivity get() = fragment.requireActivity() as MainActivity - - abstract suspend fun getMediaMetadata(items: List): List - - @OptIn(DelicateCoroutinesApi::class) - fun playAll(queueTitle: String?, items: List) { - GlobalScope.launch(Dispatchers.Main + context.exceptionHandler) { - MediaSessionConnection.binder?.songPlayer?.playQueue(ListQueue( - title = queueTitle, - items = getMediaMetadata(items).map { it.toMediaItem() } - )) - } - } - - @OptIn(DelicateCoroutinesApi::class) - fun playNext(items: List, message: String) { - val mainContent = mainActivity.binding.mainContent - GlobalScope.launch(Dispatchers.Main + context.exceptionHandler) { - MediaSessionConnection.mediaController?.sendCommand( - COMMAND_PLAY_NEXT, - bundleOf(EXTRA_MEDIA_METADATA_ITEMS to getMediaMetadata(items).toTypedArray()), - null - ) - Snackbar.make(mainContent, message, LENGTH_SHORT).show() - } - } - - @OptIn(DelicateCoroutinesApi::class) - fun addToQueue(items: List, message: String) { - val mainContent = mainActivity.binding.mainContent - GlobalScope.launch(Dispatchers.Main + context.exceptionHandler) { - MediaSessionConnection.mediaController?.sendCommand( - COMMAND_ADD_TO_QUEUE, - bundleOf(EXTRA_MEDIA_METADATA_ITEMS to getMediaMetadata(items).toTypedArray()), - null - ) - Snackbar.make(mainContent, message, LENGTH_SHORT).show() - } - } - - @OptIn(DelicateCoroutinesApi::class) - fun addToPlaylist(block: suspend (PlaylistEntity) -> Unit) { - val mainContent = mainActivity.binding.mainContent - ChoosePlaylistDialog { playlist -> - GlobalScope.launch(context.exceptionHandler) { - block(playlist) - Snackbar.make(mainContent, fragment.getString(R.string.snackbar_added_to_playlist, playlist.name), LENGTH_SHORT) - .setAction(R.string.snackbar_action_view) { - fragment.exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - fragment.reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - fragment.findNavController().navigate(R.id.playlistSongsFragment, PlaylistSongsFragmentArgs.Builder(playlist.id).build().toBundle()) - }.show() - } - }.show(fragment.childFragmentManager, null) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/listeners/CustomPlaylistMenuListener.kt b/app/src/main/java/com/zionhuang/music/ui/listeners/CustomPlaylistMenuListener.kt deleted file mode 100644 index 859a05e19..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/listeners/CustomPlaylistMenuListener.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.zionhuang.music.ui.listeners - -import androidx.fragment.app.Fragment -import com.zionhuang.music.R -import com.zionhuang.music.db.entities.DownloadedPlaylist -import com.zionhuang.music.db.entities.LikedPlaylist -import com.zionhuang.music.extensions.exceptionHandler -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.sortInfo.SongSortInfoPreference -import com.zionhuang.music.models.toMediaMetadata -import com.zionhuang.music.repos.SongRepository -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -class LikedPlaylistMenuListener(override val fragment: Fragment) : BaseMenuListener(fragment) { - private val songRepository by lazy { SongRepository(fragment.requireContext()) } - - override suspend fun getMediaMetadata(items: List): List = - songRepository.getLikedSongs(SongSortInfoPreference).getList().map { it.toMediaMetadata() } - - fun play() { - playAll(context.getString(R.string.liked_songs), emptyList()) - } - - fun playNext() { - playNext(emptyList(), context.resources.getQuantityString(R.plurals.snackbar_playlist_play_next, 1, 1)) - } - - fun addToQueue() { - addToQueue(emptyList(), context.resources.getQuantityString(R.plurals.snackbar_playlist_added_to_queue, 1, 1)) - } - - fun addToPlaylist() { - addToPlaylist { playlist -> - songRepository.addToPlaylist(playlist, songRepository.getLikedSongs(SongSortInfoPreference).getList()) - } - } - - @OptIn(DelicateCoroutinesApi::class) - fun download() { - GlobalScope.launch(context.exceptionHandler) { - songRepository.downloadSongs(songRepository.getLikedSongs(SongSortInfoPreference).getList().map { it.song }) - } - } -} - -class DownloadedPlaylistMenuListener(override val fragment: Fragment) : BaseMenuListener(fragment) { - private val songRepository by lazy { SongRepository(fragment.requireContext()) } - - override suspend fun getMediaMetadata(items: List): List = - songRepository.getDownloadedSongs(SongSortInfoPreference).getList().map { it.toMediaMetadata() } - - fun play() { - playAll(context.getString(R.string.downloaded_songs), emptyList()) - } - - fun playNext() { - playNext(emptyList(), context.resources.getQuantityString(R.plurals.snackbar_playlist_play_next, 1, 1)) - } - - fun addToQueue() { - addToQueue(emptyList(), context.resources.getQuantityString(R.plurals.snackbar_playlist_added_to_queue, 1, 1)) - } - - fun addToPlaylist() { - addToPlaylist { playlist -> - songRepository.addToPlaylist(playlist, songRepository.getLikedSongs(SongSortInfoPreference).getList()) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/listeners/PlaylistMenuListener.kt b/app/src/main/java/com/zionhuang/music/ui/listeners/PlaylistMenuListener.kt deleted file mode 100644 index bf6d771a9..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/listeners/PlaylistMenuListener.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.zionhuang.music.ui.listeners - -import android.content.Intent -import android.content.Intent.ACTION_SEND -import android.content.Intent.EXTRA_TEXT -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.models.WatchPlaylistEndpoint -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants -import com.zionhuang.music.db.entities.Playlist -import com.zionhuang.music.extensions.exceptionHandler -import com.zionhuang.music.extensions.show -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.toMediaMetadata -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.fragments.dialogs.EditPlaylistDialog -import com.zionhuang.music.utils.NavigationEndpointHandler -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -interface IPlaylistMenuListener { - fun edit(playlist: Playlist) - fun play(playlists: List) - fun playNext(playlists: List) - fun addToQueue(playlists: List) - fun addToPlaylist(playlists: List) - fun download(playlists: List) - fun share(playlist: Playlist) - fun refetch(playlists: List) - fun delete(playlists: List) - - fun play(playlist: Playlist) = play(listOf(playlist)) - fun playNext(playlist: Playlist) = playNext(listOf(playlist)) - fun addToQueue(playlist: Playlist) = addToQueue(listOf(playlist)) - fun addToPlaylist(playlist: Playlist) = addToPlaylist(listOf(playlist)) - fun download(playlist: Playlist) = download(listOf(playlist)) - fun refetch(playlist: Playlist) = refetch(listOf(playlist)) - fun delete(playlist: Playlist) = delete(listOf(playlist)) -} - -class PlaylistMenuListener(override val fragment: Fragment) : BaseMenuListener(fragment), IPlaylistMenuListener { - private val songRepository by lazy { SongRepository(fragment.requireContext()) } - - override suspend fun getMediaMetadata(items: List): List = withContext(IO) { - items.flatMap { playlist -> - if (playlist.playlist.isYouTubePlaylist) { - YouTube.getQueue(playlistId = playlist.id).getOrThrow().map { it.toMediaMetadata() } - } else { - songRepository.getPlaylistSongs(playlist.id).getList().map { it.toMediaMetadata() } - } - } - } - - override fun edit(playlist: Playlist) { - EditPlaylistDialog().apply { - arguments = bundleOf(MediaConstants.EXTRA_PLAYLIST to playlist.playlist) - }.show(context) - } - - override fun play(playlists: List) { - if (playlists.size == 1 && playlists[0].playlist.isYouTubePlaylist) { - NavigationEndpointHandler(fragment).handle(WatchPlaylistEndpoint(playlistId = playlists[0].id)) - } else { - playAll(if (playlists.size == 1) playlists[0].playlist.name else "", playlists) - } - } - - override fun playNext(playlists: List) { - playNext(playlists, context.resources.getQuantityString(R.plurals.snackbar_playlist_play_next, playlists.size, playlists.size)) - } - - override fun addToQueue(playlists: List) { - addToQueue(playlists, context.resources.getQuantityString(R.plurals.snackbar_playlist_added_to_queue, playlists.size, playlists.size)) - } - - override fun addToPlaylist(playlists: List) { - addToPlaylist { playlist -> - songRepository.addToPlaylist(playlist, playlists) - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun download(playlists: List) { - GlobalScope.launch(context.exceptionHandler) { - songRepository.downloadPlaylists(playlists) - } - } - - override fun share(playlist: Playlist) { - if (playlist.playlist.isYouTubePlaylist) { - val intent = Intent().apply { - action = ACTION_SEND - type = "text/plain" - putExtra(EXTRA_TEXT, "https://music.youtube.com/playlist?list=${playlist.id}") - } - fragment.startActivity(Intent.createChooser(intent, null)) - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun refetch(playlists: List) { - GlobalScope.launch(context.exceptionHandler) { - songRepository.refetchPlaylists(playlists) - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun delete(playlists: List) { - GlobalScope.launch(context.exceptionHandler) { - songRepository.deletePlaylists(playlists.map { it.playlist }) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/listeners/SongMenuListener.kt b/app/src/main/java/com/zionhuang/music/ui/listeners/SongMenuListener.kt deleted file mode 100644 index c408496c9..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/listeners/SongMenuListener.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.zionhuang.music.ui.listeners - -import android.content.Intent -import android.content.Intent.ACTION_SEND -import android.content.Intent.EXTRA_TEXT -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_SHORT -import com.google.android.material.snackbar.Snackbar -import com.zionhuang.innertube.models.BrowseEndpoint.Companion.albumBrowseEndpoint -import com.zionhuang.innertube.models.BrowseEndpoint.Companion.artistBrowseEndpoint -import com.zionhuang.innertube.models.BrowseLocalArtistSongsEndpoint -import com.zionhuang.innertube.models.WatchEndpoint -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_SONG -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.exceptionHandler -import com.zionhuang.music.extensions.show -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.toMediaMetadata -import com.zionhuang.music.playback.MediaSessionConnection -import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.fragments.dialogs.EditSongDialog -import com.zionhuang.music.utils.NavigationEndpointHandler -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -interface ISongMenuListener { - fun editSong(song: Song) - fun toggleLike(song: Song) - fun startRadio(song: Song) - fun playNext(songs: List) - fun addToQueue(songs: List) - fun addToPlaylist(songs: List) - fun download(songs: List) - fun removeDownload(songs: List) - fun viewArtist(song: Song) - fun viewAlbum(song: Song) - fun refetch(songs: List) - fun share(song: Song) - fun delete(songs: List) - - fun playNext(song: Song) = playNext(listOf(song)) - fun addToQueue(song: Song) = addToQueue(listOf(song)) - fun addToPlaylist(song: Song) = addToPlaylist(listOf(song)) - fun download(song: Song) = download(listOf(song)) - fun removeDownload(song: Song) = removeDownload(listOf(song)) - fun refetch(song: Song) = refetch(listOf(song)) - fun delete(song: Song) = delete(listOf(song)) -} - -class SongMenuListener(override val fragment: Fragment) : BaseMenuListener(fragment), ISongMenuListener { - private val songRepository by lazy { SongRepository(fragment.requireContext()) } - - override suspend fun getMediaMetadata(items: List): List = items.map { it.toMediaMetadata() } - - override fun editSong(song: Song) { - EditSongDialog().apply { - arguments = bundleOf(EXTRA_SONG to song) - }.show(context) - } - - @OptIn(DelicateCoroutinesApi::class) - override fun toggleLike(song: Song) { - GlobalScope.launch(context.exceptionHandler) { - songRepository.toggleLiked(song) - } - } - - override fun startRadio(song: Song) { - MediaSessionConnection.binder?.songPlayer?.playQueue(YouTubeQueue(endpoint = WatchEndpoint(videoId = song.id))) - } - - override fun playNext(songs: List) { - playNext(songs, context.resources.getQuantityString(R.plurals.snackbar_song_play_next, songs.size, songs.size)) - } - - override fun addToQueue(songs: List) { - addToQueue(songs, context.resources.getQuantityString(R.plurals.snackbar_song_added_to_queue, songs.size, songs.size)) - } - - override fun addToPlaylist(songs: List) { - addToPlaylist { playlist -> - songRepository.addToPlaylist(playlist, songs) - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun download(songs: List) { - GlobalScope.launch(context.exceptionHandler) { - Snackbar.make(mainActivity.binding.mainContent, context.resources.getQuantityString(R.plurals.snackbar_download_song, songs.size, songs.size), LENGTH_SHORT).show() - songRepository.downloadSongs(songs.map { it.song }) - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun removeDownload(songs: List) { - val mainContent = mainActivity.binding.mainContent - GlobalScope.launch { - songRepository.removeDownloads(songs) - Snackbar.make(mainContent, R.string.snackbar_removed_download, LENGTH_SHORT).show() - } - } - - override fun viewArtist(song: Song) { - if (song.artists.isNotEmpty()) { - val artist = song.artists[0] - NavigationEndpointHandler(fragment).handle(if (artist.isYouTubeArtist) { - artistBrowseEndpoint(artist.id) - } else { - BrowseLocalArtistSongsEndpoint(artist.id) - }) - } - } - - override fun viewAlbum(song: Song) { - if (song.song.albumId != null) { - NavigationEndpointHandler(fragment).handle(albumBrowseEndpoint(song.song.albumId)) - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun refetch(songs: List) { - GlobalScope.launch(context.exceptionHandler) { - songRepository.refetchSongs(songs) - } - } - - override fun share(song: Song) { - val intent = Intent().apply { - action = ACTION_SEND - type = "text/plain" - putExtra(EXTRA_TEXT, "https://music.youtube.com/watch?v=${song.id}") - } - fragment.startActivity(Intent.createChooser(intent, null)) - } - - @OptIn(DelicateCoroutinesApi::class) - override fun delete(songs: List) { - GlobalScope.launch { - songRepository.deleteSongs(songs) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/listeners/YTItemBatchMenuListener.kt b/app/src/main/java/com/zionhuang/music/ui/listeners/YTItemBatchMenuListener.kt deleted file mode 100644 index 35268d714..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/listeners/YTItemBatchMenuListener.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.zionhuang.music.ui.listeners - -import android.content.Context -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_SHORT -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.models.* -import com.zionhuang.music.R -import com.zionhuang.music.extensions.exceptionHandler -import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.playback.MediaSessionConnection -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.activities.MainActivity -import com.zionhuang.music.ui.fragments.dialogs.ChoosePlaylistDialog -import com.zionhuang.music.ui.fragments.songs.PlaylistSongsFragmentArgs -import kotlinx.coroutines.* -import kotlinx.coroutines.Dispatchers.IO - -interface IYTItemBatchMenuListener { - fun playNext(items: List) - fun addToQueue(items: List) - fun addToLibrary(items: List) - fun addToPlaylist(items: List) - fun download(items: List) -} - -class YTItemBatchMenuListener(val fragment: Fragment) : IYTItemBatchMenuListener { - private val songRepository by lazy { SongRepository(fragment.requireContext()) } - val context: Context - get() = fragment.requireContext() - - val mainActivity: MainActivity - get() = fragment.requireActivity() as MainActivity - - @OptIn(DelicateCoroutinesApi::class) - override fun playNext(items: List) { - val mainContent = mainActivity.binding.mainContent - GlobalScope.launch(Dispatchers.Main + context.exceptionHandler) { - MediaSessionConnection.binder?.songPlayer?.playNext(items.flatMap { item -> - when (item) { - is SongItem -> listOf(item.toMediaItem()) - is AlbumItem -> withContext(IO) { - YouTube.browse(BrowseEndpoint(browseId = "VL" + item.playlistId)).getOrThrow().items.filterIsInstance().map { it.toMediaItem() } - // consider refetch by [YouTube.getQueue] if needed - } - is PlaylistItem -> withContext(IO) { - YouTube.getQueue(playlistId = item.id).getOrThrow().map { it.toMediaItem() } - } - is ArtistItem -> emptyList() - } - }) - Snackbar.make( - mainContent, - when { - items.all { it is SongItem } -> context.resources.getQuantityString(R.plurals.snackbar_song_play_next, items.size, items.size) - items.all { it is AlbumItem } -> context.resources.getQuantityString(R.plurals.snackbar_album_play_next, items.size, items.size) - items.all { it is PlaylistItem } -> context.resources.getQuantityString(R.plurals.snackbar_playlist_play_next, items.size, items.size) - else -> context.getString(R.string.snackbar_play_next) - }, - LENGTH_SHORT - ).show() - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun addToQueue(items: List) { - val mainContent = mainActivity.binding.mainContent - GlobalScope.launch(Dispatchers.Main + context.exceptionHandler) { - MediaSessionConnection.binder?.songPlayer?.addToQueue(items.flatMap { item -> - when (item) { - is SongItem -> listOf(item.toMediaItem()) - is AlbumItem -> withContext(IO) { - YouTube.browse(BrowseEndpoint(browseId = "VL" + item.playlistId)).getOrThrow().items.filterIsInstance().map { it.toMediaItem() } - // consider refetch by [YouTube.getQueue] if needed - } - is PlaylistItem -> withContext(IO) { - YouTube.getQueue(playlistId = item.id).getOrThrow().map { it.toMediaItem() } - } - is ArtistItem -> emptyList() - } - }) - Snackbar.make( - mainContent, - when { - items.all { it is SongItem } -> context.resources.getQuantityString(R.plurals.snackbar_song_added_to_queue, items.size, items.size) - items.all { it is AlbumItem } -> context.resources.getQuantityString(R.plurals.snackbar_album_added_to_queue, items.size, items.size) - items.all { it is PlaylistItem } -> context.resources.getQuantityString(R.plurals.snackbar_playlist_added_to_queue, items.size, items.size) - else -> context.getString(R.string.snackbar_added_to_queue) - }, - LENGTH_SHORT - ).show() - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun addToLibrary(items: List) { - val mainContent = mainActivity.binding.mainContent - GlobalScope.launch(context.exceptionHandler) { - songRepository.safeAddSongs(items.filterIsInstance()) - songRepository.addAlbums(items.filterIsInstance()) - songRepository.addPlaylists(items.filterIsInstance()) - Snackbar.make(mainContent, R.string.snackbar_added_to_library, LENGTH_SHORT).show() - } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun addToPlaylist(items: List) { - val mainContent = mainActivity.binding.mainContent - ChoosePlaylistDialog { playlist -> - GlobalScope.launch(context.exceptionHandler) { - songRepository.addYouTubeItemsToPlaylist(playlist, items) - Snackbar.make(mainContent, fragment.getString(R.string.snackbar_added_to_playlist, playlist.name), LENGTH_SHORT) - .setAction(R.string.snackbar_action_view) { - fragment.exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - fragment.reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - fragment.findNavController().navigate(R.id.playlistSongsFragment, PlaylistSongsFragmentArgs.Builder(playlist.id).build().toBundle()) - }.show() - } - }.show(fragment.childFragmentManager, null) - } - - override fun download(items: List) { - TODO() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/AddToPlaylistDialog.kt b/app/src/main/java/com/zionhuang/music/ui/menu/AddToPlaylistDialog.kt new file mode 100644 index 000000000..e1279a386 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/AddToPlaylistDialog.kt @@ -0,0 +1,100 @@ +package com.zionhuang.music.ui.menu + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.R +import com.zionhuang.music.constants.ListThumbnailSize +import com.zionhuang.music.db.entities.Playlist +import com.zionhuang.music.db.entities.PlaylistEntity +import com.zionhuang.music.ui.component.ListDialog +import com.zionhuang.music.ui.component.ListItem +import com.zionhuang.music.ui.component.PlaylistListItem +import com.zionhuang.music.ui.component.TextFieldDialog + +@Composable +fun AddToPlaylistDialog( + isVisible: Boolean, + onAdd: (Playlist) -> Unit, + onDismiss: () -> Unit, +) { + val database = LocalDatabase.current + var playlists by remember { + mutableStateOf(emptyList()) + } + var showCreatePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + LaunchedEffect(Unit) { + database.playlistsByCreateDateAsc().collect { + playlists = it.asReversed() + } + } + + if (isVisible) { + ListDialog( + onDismiss = onDismiss + ) { + item { + ListItem( + title = stringResource(R.string.create_playlist), + thumbnailContent = { + Image( + painter = painterResource(R.drawable.add), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(ListThumbnailSize) + ) + }, + modifier = Modifier.clickable { + showCreatePlaylistDialog = true + } + ) + } + + items(playlists) { playlist -> + PlaylistListItem( + playlist = playlist, + modifier = Modifier.clickable { + onAdd(playlist) + onDismiss() + } + ) + } + } + } + + if (showCreatePlaylistDialog) { + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.add), contentDescription = null) }, + title = { Text(text = stringResource(R.string.create_playlist)) }, + onDismiss = { showCreatePlaylistDialog = false }, + onDone = { playlistName -> + database.query { + insert( + PlaylistEntity( + name = playlistName + ) + ) + } + } + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/AlbumMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/AlbumMenu.kt new file mode 100644 index 000000000..0dfa5b39b --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/AlbumMenu.kt @@ -0,0 +1,264 @@ +package com.zionhuang.music.ui.menu + +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED +import androidx.media3.exoplayer.offline.Download.STATE_DOWNLOADING +import androidx.media3.exoplayer.offline.Download.STATE_QUEUED +import androidx.media3.exoplayer.offline.Download.STATE_STOPPED +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.R +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.constants.ListThumbnailSize +import com.zionhuang.music.db.entities.Album +import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.db.entities.Song +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.playback.ExoDownloadService +import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.ui.component.DownloadGridMenu +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem +import com.zionhuang.music.ui.component.ListDialog + +@Composable +fun AlbumMenu( + album: Album, + navController: NavController, + playerConnection: PlayerConnection, + showDeleteButton: Boolean = true, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + var songs by remember { + mutableStateOf(emptyList()) + } + + LaunchedEffect(Unit) { + database.albumSongs(album.id).collect { + songs = it + } + } + + var downloadState by remember { + mutableStateOf(STATE_STOPPED) + } + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.id]?.state == STATE_COMPLETED }) + STATE_COMPLETED + else if (songs.all { + downloads[it.id]?.state == STATE_QUEUED + || downloads[it.id]?.state == STATE_DOWNLOADING + || downloads[it.id]?.state == STATE_COMPLETED + }) + STATE_DOWNLOADING + else + STATE_STOPPED + } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onAdd = { playlist -> + database.transaction { + songs.map { it.id } + .forEach { + insert( + PlaylistSongMap( + songId = it, + playlistId = playlist.id, + position = playlist.songCount + ) + ) + } + } + }, + onDismiss = { + showChoosePlaylistDialog = false + } + ) + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items( + items = album.artists, + key = { it.id } + ) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + modifier = Modifier.padding(8.dp), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = artist.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(ListThumbnailSize) + .clip(CircleShape) + ) + } + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) + } + } + } + } + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + GridMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next + ) { + onDismiss() + playerConnection.playNext(songs.map { it.toMediaItem() }) + } + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + onDismiss() + playerConnection.addToQueue(songs.map { it.toMediaItem() }) + } + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + DownloadGridMenu( + state = downloadState, + onDownload = { + songs.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + onRemoveDownload = { + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + } + ) + GridMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist + ) { + if (album.artists.size == 1) { + navController.navigate("artist/${album.artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } + } + GridMenuItem( + icon = R.drawable.share, + title = R.string.share + ) { + onDismiss() + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/browse/${album.album.id}") + } + context.startActivity(Intent.createChooser(intent, null)) + } + if (showDeleteButton) { + GridMenuItem( + icon = R.drawable.delete, + title = R.string.delete + ) { + onDismiss() + database.query { + delete(album.album) + } + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/PlaylistMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/PlaylistMenu.kt new file mode 100644 index 000000000..308a99354 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/PlaylistMenu.kt @@ -0,0 +1,112 @@ +package com.zionhuang.music.ui.menu + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.utils.completed +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.R +import com.zionhuang.music.db.entities.Playlist +import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem +import com.zionhuang.music.ui.component.TextFieldDialog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun PlaylistMenu( + playlist: Playlist, + coroutineScope: CoroutineScope, + onDismiss: () -> Unit, +) { + val database = LocalDatabase.current + + var showEditDialog by remember { + mutableStateOf(false) + } + + if (showEditDialog) { + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.edit), contentDescription = null) }, + title = { Text(text = stringResource(R.string.edit_playlist)) }, + onDismiss = { showEditDialog = false }, + initialTextFieldValue = TextFieldValue(playlist.playlist.name, TextRange(playlist.playlist.name.length)), + onDone = { name -> + onDismiss() + database.query { + update(playlist.playlist.copy(name = name)) + } + } + ) + } + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + GridMenuItem( + icon = R.drawable.edit, + title = R.string.edit + ) { + showEditDialog = true + } + + if (playlist.playlist.browseId != null) { + GridMenuItem( + icon = R.drawable.sync, + title = R.string.sync + ) { + onDismiss() + coroutineScope.launch(Dispatchers.IO) { + val playlistPage = YouTube.playlist(playlist.playlist.browseId).completed().getOrNull() ?: return@launch + database.transaction { + clearPlaylist(playlist.id) + playlistPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { position, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = position + ) + } + .forEach(::insert) + } + } + } + } + + GridMenuItem( + icon = R.drawable.delete, + title = R.string.delete + ) { + onDismiss() + database.query { + delete(playlist.playlist) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/SongMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/SongMenu.kt new file mode 100644 index 000000000..af363a5a4 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/SongMenu.kt @@ -0,0 +1,317 @@ +package com.zionhuang.music.ui.menu + +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.R +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.constants.ListThumbnailSize +import com.zionhuang.music.db.entities.Event +import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.db.entities.Song +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.ExoDownloadService +import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.DownloadGridMenu +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem +import com.zionhuang.music.ui.component.ListDialog +import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.component.TextFieldDialog + +@Composable +fun SongMenu( + originalSong: Song, + event: Event? = null, + navController: NavController, + playerConnection: PlayerConnection, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val songState = database.song(originalSong.id).collectAsState(initial = originalSong) + val song = songState.value ?: originalSong + val download by LocalDownloadUtil.current.getDownload(originalSong.id).collectAsState(initial = null) + + var showEditDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showEditDialog) { + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.edit), contentDescription = null) }, + title = { Text(text = stringResource(R.string.edit_song)) }, + onDismiss = { showEditDialog = false }, + initialTextFieldValue = TextFieldValue(song.song.title, TextRange(song.song.title.length)), + onDone = { title -> + onDismiss() + database.query { + update(song.song.copy(title = title)) + } + } + ) + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onAdd = { playlist -> + database.query { + insert( + PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = playlist.songCount + ) + ) + } + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items( + items = song.artists, + key = { it.id } + ) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + modifier = Modifier.padding(8.dp), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = artist.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(ListThumbnailSize) + .clip(CircleShape) + ) + } + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) + } + } + } + } + + SongListItem( + song = song, + badges = {}, + trailingContent = { + IconButton( + onClick = { + database.query { + update(song.song.toggleLike()) + } + } + ) { + Icon( + painter = painterResource(if (song.song.liked) R.drawable.favorite else R.drawable.favorite_border), + tint = MaterialTheme.colorScheme.error, + contentDescription = null + ) + } + } + ) + + Divider() + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + GridMenuItem( + icon = R.drawable.radio, + title = R.string.start_radio + ) { + onDismiss() + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + GridMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next + ) { + onDismiss() + playerConnection.playNext(song.toMediaItem()) + } + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + onDismiss() + playerConnection.addToQueue((song.toMediaItem())) + } + GridMenuItem( + icon = R.drawable.edit, + title = R.string.edit + ) { + showEditDialog = true + } + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + DownloadGridMenu( + state = download?.state, + onDownload = { + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + }, + onRemoveDownload = { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + ) + GridMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist + ) { + if (song.artists.size == 1) { + navController.navigate("artist/${song.artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } + } + if (song.song.albumId != null) { + GridMenuItem( + icon = R.drawable.album, + title = R.string.view_album + ) { + onDismiss() + navController.navigate("album/${song.song.albumId}") + } + } + GridMenuItem( + icon = R.drawable.share, + title = R.string.share + ) { + onDismiss() + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${song.id}") + } + context.startActivity(Intent.createChooser(intent, null)) + } + if (song.song.inLibrary == null) { + GridMenuItem( + icon = R.drawable.library_add, + title = R.string.add_to_library + ) { + database.query { + update(song.song.toggleLibrary()) + } + } + } else { + GridMenuItem( + icon = R.drawable.library_add_check, + title = R.string.remove_from_library + ) { + database.query { + update(song.song.toggleLibrary()) + } + } + } + if (event != null) { + GridMenuItem( + icon = R.drawable.delete, + title = R.string.remove_from_history + ) { + onDismiss() + database.query { + delete(event) + } + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeAlbumMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeAlbumMenu.kt new file mode 100644 index 000000000..3fd63d30f --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeAlbumMenu.kt @@ -0,0 +1,283 @@ +package com.zionhuang.music.ui.menu + +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.pages.AlbumPage +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.R +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.ExoDownloadService +import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.playback.queues.YouTubeAlbumRadio +import com.zionhuang.music.ui.component.DownloadGridMenu +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem +import com.zionhuang.music.ui.component.ListDialog + +@Composable +fun YouTubeAlbumMenu( + album: AlbumItem, + navController: NavController, + playerConnection: PlayerConnection, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val libraryAlbum by database.album(album.id).collectAsState(initial = null) + var albumPage: AlbumPage? by remember { + mutableStateOf(null) + } + + LaunchedEffect(Unit) { + YouTube.album(album.browseId).onSuccess { + albumPage = it + } + } + + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(albumPage) { + val songs = albumPage?.songs?.map { it.id } ?: return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it]?.state == Download.STATE_COMPLETED }) + Download.STATE_COMPLETED + else if (songs.all { + downloads[it]?.state == Download.STATE_QUEUED + || downloads[it]?.state == Download.STATE_DOWNLOADING + || downloads[it]?.state == Download.STATE_COMPLETED + }) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onAdd = { playlist -> + var position = playlist.songCount + database.transaction { + albumPage?.let { albumPage -> + albumPage.songs + .map { it.toMediaMetadata() } + .onEach(::insert) + .forEach { song -> + insert( + PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = position++ + ) + ) + } + } + } + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items( + items = album.artists.orEmpty(), + key = { it.id!! } + ) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + showSelectArtistDialog = false + onDismiss() + navController.navigate("artist/${artist.id}") + } + .padding(horizontal = 24.dp), + ) { + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + GridMenuItem( + icon = R.drawable.radio, + title = R.string.start_radio + ) { + playerConnection.playQueue(YouTubeAlbumRadio(album.playlistId)) + onDismiss() + } + GridMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next + ) { + albumPage?.songs + ?.map { it.toMediaItem() } + ?.let(playerConnection::playNext) + onDismiss() + } + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + albumPage?.songs + ?.map { it.toMediaItem() } + ?.let(playerConnection::addToQueue) + onDismiss() + } + if (libraryAlbum != null) { + GridMenuItem( + icon = R.drawable.library_add_check, + title = R.string.remove_from_library + ) { + database.query { + libraryAlbum?.album?.let(::delete) + } + } + } else { + GridMenuItem( + icon = R.drawable.library_add, + title = R.string.add_to_library + ) { + database.transaction { + albumPage?.let(::insert) + } + } + } + + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + DownloadGridMenu( + state = downloadState, + onDownload = { + albumPage?.songs?.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + onRemoveDownload = { + albumPage?.songs?.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + } + ) + album.artists?.let { artists -> + GridMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist + ) { + if (artists.size == 1) { + navController.navigate("artist/${artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } + } + } + GridMenuItem( + icon = R.drawable.share, + title = R.string.share + ) { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, album.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeArtistMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeArtistMenu.kt new file mode 100644 index 000000000..117721f79 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeArtistMenu.kt @@ -0,0 +1,65 @@ +package com.zionhuang.music.ui.menu + +import android.content.Intent +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.zionhuang.innertube.models.ArtistItem +import com.zionhuang.music.R +import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem + +@Composable +fun YouTubeArtistMenu( + artist: ArtistItem, + playerConnection: PlayerConnection, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + artist.radioEndpoint?.let { watchEndpoint -> + GridMenuItem( + icon = R.drawable.radio, + title = R.string.start_radio + ) { + playerConnection.playQueue(YouTubeQueue(watchEndpoint)) + onDismiss() + } + } + artist.shuffleEndpoint?.let { watchEndpoint -> + GridMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle + ) { + playerConnection.playQueue(YouTubeQueue(watchEndpoint)) + onDismiss() + } + } + GridMenuItem( + icon = R.drawable.share, + title = R.string.share + ) { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, artist.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubePlaylistMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubePlaylistMenu.kt new file mode 100644 index 000000000..705a86b10 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubePlaylistMenu.kt @@ -0,0 +1,158 @@ +package com.zionhuang.music.ui.menu + +import android.content.Intent +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.utils.completed +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.R +import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun YouTubePlaylistMenu( + playlist: PlaylistItem, + songs: List = emptyList(), + playerConnection: PlayerConnection, + coroutineScope: CoroutineScope, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onAdd = { targetPlaylist -> + coroutineScope.launch(Dispatchers.IO) { + var position = targetPlaylist.songCount + songs.ifEmpty { + withContext(Dispatchers.IO) { + YouTube.playlist(playlist.id).completed().getOrNull()?.songs.orEmpty() + } + }.let { songs -> + database.transaction { + songs + .map { it.toMediaMetadata() } + .onEach(::insert) + .forEach { song -> + insert( + PlaylistSongMap( + songId = song.id, + playlistId = targetPlaylist.id, + position = position++ + ) + ) + } + } + } + } + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + playlist.playEndpoint?.let { + GridMenuItem( + icon = R.drawable.play, + title = R.string.play + ) { + playerConnection.playQueue(YouTubeQueue(it)) + onDismiss() + } + } + GridMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle + ) { + playerConnection.playQueue(YouTubeQueue(playlist.shuffleEndpoint)) + onDismiss() + } + GridMenuItem( + icon = R.drawable.radio, + title = R.string.start_radio + ) { + playerConnection.playQueue(YouTubeQueue(playlist.radioEndpoint)) + onDismiss() + } + GridMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next + ) { + coroutineScope.launch { + songs.ifEmpty { + withContext(Dispatchers.IO) { + YouTube.playlist(playlist.id).completed().getOrNull()?.songs.orEmpty() + } + }.let { songs -> + playerConnection.playNext(songs.map { it.toMediaItem() }) + } + } + onDismiss() + } + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + coroutineScope.launch(Dispatchers.IO) { + songs.ifEmpty { + withContext(Dispatchers.IO) { + YouTube.playlist(playlist.id).completed().getOrNull()?.songs.orEmpty() + } + }.let { songs -> + playerConnection.addToQueue(songs.map { it.toMediaItem() }) + } + } + onDismiss() + } + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + GridMenuItem( + icon = R.drawable.share, + title = R.string.share + ) { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, playlist.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongMenu.kt new file mode 100644 index 000000000..655b932a0 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongMenu.kt @@ -0,0 +1,308 @@ +package com.zionhuang.music.ui.menu + +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.R +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.constants.ListThumbnailSize +import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.db.entities.SongEntity +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.ExoDownloadService +import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.DownloadGridMenu +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem +import com.zionhuang.music.ui.component.ListDialog +import com.zionhuang.music.ui.component.ListItem +import com.zionhuang.music.utils.joinByBullet +import com.zionhuang.music.utils.makeTimeString +import java.time.LocalDateTime + +@Composable +fun YouTubeSongMenu( + song: SongItem, + navController: NavController, + playerConnection: PlayerConnection, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val librarySong by database.song(song.id).collectAsState(initial = null) + val download by LocalDownloadUtil.current.getDownload(song.id).collectAsState(initial = null) + val artists = remember { + song.artists.mapNotNull { + it.id?.let { artistId -> + MediaMetadata.Artist(id = artistId, name = it.name) + } + } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onAdd = { playlist -> + database.transaction { + insert(song.toMediaMetadata()) + insert( + PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = playlist.songCount + ) + ) + } + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items(artists) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 24.dp), + ) { + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + + ListItem( + title = song.title, + subtitle = joinByBullet( + song.artists.joinToString { it.name }, + song.duration?.let { makeTimeString(it * 1000L) } + ), + thumbnailContent = { + AsyncImage( + model = song.thumbnail, + contentDescription = null, + modifier = Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + }, + trailingContent = { + IconButton( + onClick = { + database.transaction { + librarySong.let { librarySong -> + if (librarySong == null) { + insert(song.toMediaMetadata(), SongEntity::toggleLike) + } else { + update(librarySong.song.toggleLike()) + } + } + } + } + ) { + Icon( + painter = painterResource(if (librarySong?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border), + tint = MaterialTheme.colorScheme.error, + contentDescription = null + ) + } + } + ) + + Divider() + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + GridMenuItem( + icon = R.drawable.radio, + title = R.string.start_radio + ) { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + onDismiss() + } + GridMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next + ) { + playerConnection.playNext(song.toMediaItem()) + onDismiss() + } + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + playerConnection.addToQueue((song.toMediaItem())) + onDismiss() + } + if (librarySong?.song?.inLibrary != null) { + GridMenuItem( + icon = R.drawable.library_add_check, + title = R.string.remove_from_library + ) { + database.query { + inLibrary(song.id, null) + } + } + } else { + GridMenuItem( + icon = R.drawable.library_add, + title = R.string.add_to_library + ) { + database.transaction { + insert(song.toMediaMetadata()) + inLibrary(song.id, LocalDateTime.now()) + } + } + } + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + DownloadGridMenu( + state = download?.state, + onDownload = { + database.transaction { + insert(song.toMediaMetadata()) + } + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + }, + onRemoveDownload = { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + ) + if (artists.isNotEmpty()) { + GridMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist + ) { + if (artists.size == 1) { + navController.navigate("artist/${artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } + } + } + song.album?.let { album -> + GridMenuItem( + icon = R.drawable.album, + title = R.string.view_album + ) { + navController.navigate("album/${album.id}") + onDismiss() + } + } + GridMenuItem( + icon = R.drawable.share, + title = R.string.share + ) { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, song.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/player/MiniPlayer.kt b/app/src/main/java/com/zionhuang/music/ui/player/MiniPlayer.kt new file mode 100644 index 000000000..830e3a928 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/player/MiniPlayer.kt @@ -0,0 +1,159 @@ +package com.zionhuang.music.ui.player + +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.media3.common.Player +import coil.compose.AsyncImage +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.MiniPlayerHeight +import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.MediaMetadata + +@Composable +fun MiniPlayer( + mediaMetadata: MediaMetadata?, + position: Long, + duration: Long, + modifier: Modifier = Modifier, +) { + mediaMetadata ?: return + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val playbackState by playerConnection.playbackState.collectAsState() + val canSkipNext by playerConnection.canSkipNext.collectAsState() + val error by playerConnection.error.collectAsState() + + Box( + modifier = modifier + .fillMaxWidth() + .height(MiniPlayerHeight) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + ) { + LinearProgressIndicator( + progress = (position.toFloat() / duration).coerceIn(0f, 1f), + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .align(Alignment.BottomCenter) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxSize() + .padding(horizontal = 6.dp), + ) { + Box(modifier = Modifier.padding(6.dp)) { + AsyncImage( + model = mediaMetadata.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + androidx.compose.animation.AnimatedVisibility( + visible = error != null, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + Modifier + .size(48.dp) + .background( + color = Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .align(Alignment.Center) + ) + } + } + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 6.dp) + ) { + Text( + text = mediaMetadata.title, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = mediaMetadata.artists.joinToString { it.name }, + color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + IconButton( + onClick = { + if (playbackState == Player.STATE_ENDED) { + playerConnection.player.seekTo(0, 0) + playerConnection.player.playWhenReady = true + } else { + playerConnection.player.togglePlayPause() + } + } + ) { + Icon( + painter = painterResource(if (playbackState == Player.STATE_ENDED) R.drawable.replay else if (isPlaying) R.drawable.pause else R.drawable.play), + contentDescription = null + ) + } + IconButton( + enabled = canSkipNext, + onClick = playerConnection.player::seekToNext + ) { + Icon( + painter = painterResource(R.drawable.skip_next), + contentDescription = null + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt b/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt new file mode 100644 index 000000000..1ca71cac5 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt @@ -0,0 +1,44 @@ +package com.zionhuang.music.ui.player + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.PlaybackException +import com.zionhuang.music.R + +@Composable +fun PlaybackError( + error: PlaybackException, + retry: () -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.pointerInput(Unit) { + detectTapGestures( + onTap = { retry() } + ) + } + ) { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + + Text( + text = error.cause?.cause?.message ?: stringResource(R.string.error_unknown), + style = MaterialTheme.typography.bodyMedium + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Player.kt b/app/src/main/java/com/zionhuang/music/ui/player/Player.kt new file mode 100644 index 000000000..71a388061 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/player/Player.kt @@ -0,0 +1,362 @@ +package com.zionhuang.music.ui.player + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.media3.common.C +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.REPEAT_MODE_ONE +import androidx.media3.common.Player.STATE_ENDED +import androidx.media3.common.Player.STATE_READY +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.QueuePeekHeight +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.ui.component.BottomSheet +import com.zionhuang.music.ui.component.BottomSheetState +import com.zionhuang.music.ui.component.ResizableIconButton +import com.zionhuang.music.ui.component.rememberBottomSheetState +import com.zionhuang.music.utils.makeTimeString +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +@Composable +fun BottomSheetPlayer( + state: BottomSheetState, + navController: NavController, + modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current ?: return + + val playbackState by playerConnection.playbackState.collectAsState() + val isPlaying by playerConnection.isPlaying.collectAsState() + val repeatMode by playerConnection.repeatMode.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val currentSong by playerConnection.currentSong.collectAsState(initial = null) + + val canSkipPrevious by playerConnection.canSkipPrevious.collectAsState() + val canSkipNext by playerConnection.canSkipNext.collectAsState() + + var position by rememberSaveable(playbackState) { + mutableStateOf(playerConnection.player.currentPosition) + } + var duration by rememberSaveable(playbackState) { + mutableStateOf(playerConnection.player.duration) + } + var sliderPosition by remember { + mutableStateOf(null) + } + + LaunchedEffect(playbackState) { + if (playbackState == STATE_READY) { + while (isActive) { + delay(500) + position = playerConnection.player.currentPosition + duration = playerConnection.player.duration + } + } + } + + val queueSheetState = rememberBottomSheetState( + dismissedBound = QueuePeekHeight + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + expandedBound = state.expandedBound, + ) + + BottomSheet( + state = state, + modifier = modifier, + backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation), + onDismiss = { + playerConnection.player.stop() + playerConnection.player.clearMediaItems() + }, + collapsedContent = { + MiniPlayer( + mediaMetadata = mediaMetadata, + position = position, + duration = duration + ) + } + ) { + val controlsContent: @Composable ColumnScope.(MediaMetadata) -> Unit = { mediaMetadata -> + Text( + text = mediaMetadata.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Spacer(Modifier.height(6.dp)) + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + mediaMetadata.artists.fastForEachIndexed { index, artist -> + Text( + text = artist.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + modifier = Modifier.clickable(enabled = artist.id != null) { + navController.navigate("artist/${artist.id}") + state.collapseSoft() + } + ) + + if (index != mediaMetadata.artists.lastIndex) { + Text( + text = ", ", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + + Slider( + value = (sliderPosition ?: position).toFloat(), + valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), + onValueChange = { + sliderPosition = it.toLong() + }, + onValueChangeFinished = { + sliderPosition?.let { + playerConnection.player.seekTo(it) + position = it + } + sliderPosition = null + }, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + Text( + text = makeTimeString(sliderPosition ?: position), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = if (duration != C.TIME_UNSET) makeTimeString(duration) else "", + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(Modifier.height(12.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Box(modifier = Modifier.weight(1f)) { + ResizableIconButton( + icon = if (currentSong?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(32.dp) + .padding(4.dp) + .align(Alignment.Center), + onClick = playerConnection::toggleLike + ) + } + + Box(modifier = Modifier.weight(1f)) { + ResizableIconButton( + icon = R.drawable.skip_previous, + enabled = canSkipPrevious, + modifier = Modifier + .size(32.dp) + .align(Alignment.Center), + onClick = playerConnection.player::seekToPrevious + ) + } + + Spacer(Modifier.width(8.dp)) + + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer) + .clickable { + if (playbackState == STATE_ENDED) { + playerConnection.player.seekTo(0, 0) + playerConnection.player.playWhenReady = true + } else { + playerConnection.player.togglePlayPause() + } + } + ) { + Image( + painter = painterResource(if (playbackState == STATE_ENDED) R.drawable.replay else if (isPlaying) R.drawable.pause else R.drawable.play), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + modifier = Modifier + .align(Alignment.Center) + .size(36.dp) + ) + } + + Spacer(Modifier.width(8.dp)) + + Box(modifier = Modifier.weight(1f)) { + ResizableIconButton( + icon = R.drawable.skip_next, + enabled = canSkipNext, + modifier = Modifier + .size(32.dp) + .align(Alignment.Center), + onClick = playerConnection.player::seekToNext + ) + } + + Box(modifier = Modifier.weight(1f)) { + ResizableIconButton( + icon = when (repeatMode) { + REPEAT_MODE_OFF, REPEAT_MODE_ALL -> R.drawable.repeat + REPEAT_MODE_ONE -> R.drawable.repeat_one + else -> throw IllegalStateException() + }, + modifier = Modifier + .size(32.dp) + .padding(4.dp) + .align(Alignment.Center) + .alpha(if (repeatMode == REPEAT_MODE_OFF) 0.5f else 1f), + onClick = playerConnection::toggleRepeatMode + ) + } + } + } + + when (LocalConfiguration.current.orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + Row( + modifier = Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(bottom = queueSheetState.collapsedBound) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f) + ) { + Thumbnail( + sliderPositionProvider = { sliderPosition }, + modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection) + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) + ) { + Spacer(Modifier.weight(1f)) + + mediaMetadata?.let { + controlsContent(it) + } + + Spacer(Modifier.weight(1f)) + } + } + } + + else -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(bottom = queueSheetState.collapsedBound) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f) + ) { + Thumbnail( + sliderPositionProvider = { sliderPosition }, + modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection) + ) + } + + mediaMetadata?.let { + controlsContent(it) + } + + Spacer(Modifier.height(24.dp)) + } + } + } + + Queue( + state = queueSheetState, + playerBottomSheetState = state, + navController = navController + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt new file mode 100644 index 000000000..444c5c393 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt @@ -0,0 +1,751 @@ +package com.zionhuang.music.ui.player + +import android.content.Intent +import android.media.audiofx.AudioEffect +import android.text.format.Formatter +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DismissValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.SwipeToDismiss +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDismissState +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder +import androidx.navigation.NavController +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.constants.ShowLyricsKey +import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.extensions.metadata +import com.zionhuang.music.extensions.move +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.playback.ExoDownloadService +import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.ui.component.BigSeekBar +import com.zionhuang.music.ui.component.BottomSheet +import com.zionhuang.music.ui.component.BottomSheetState +import com.zionhuang.music.ui.component.DownloadGridMenu +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem +import com.zionhuang.music.ui.component.ListDialog +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.MediaMetadataListItem +import com.zionhuang.music.ui.menu.AddToPlaylistDialog +import com.zionhuang.music.ui.utils.reordering.ReorderingLazyColumn +import com.zionhuang.music.ui.utils.reordering.draggedItem +import com.zionhuang.music.ui.utils.reordering.rememberReorderingState +import com.zionhuang.music.ui.utils.reordering.reorder +import com.zionhuang.music.utils.makeTimeString +import com.zionhuang.music.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun Queue( + state: BottomSheetState, + playerBottomSheetState: BottomSheetState, + navController: NavController, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val menuState = LocalMenuState.current + + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + + val currentWindowIndex by playerConnection.currentWindowIndex.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val currentSong by playerConnection.currentSong.collectAsState(initial = null) + val currentFormat by playerConnection.currentFormat.collectAsState(initial = null) + + var showLyrics by rememberPreference(ShowLyricsKey, defaultValue = false) + + val sleepTimerEnabled = remember(playerConnection.service.sleepTimerTriggerTime, playerConnection.service.pauseWhenSongEnd) { + playerConnection.service.sleepTimerTriggerTime != -1L || playerConnection.service.pauseWhenSongEnd + } + + var sleepTimerTimeLeft by remember { + mutableStateOf(0L) + } + + LaunchedEffect(sleepTimerEnabled) { + if (sleepTimerEnabled) { + while (isActive) { + sleepTimerTimeLeft = if (playerConnection.service.pauseWhenSongEnd) { + playerConnection.player.duration - playerConnection.player.currentPosition + } else { + playerConnection.service.sleepTimerTriggerTime - System.currentTimeMillis() + } + delay(1000L) + } + } + } + + var showSleepTimerDialog by remember { + mutableStateOf(false) + } + + var sleepTimerValue by remember { + mutableStateOf(30f) + } + if (showSleepTimerDialog) { + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { showSleepTimerDialog = false }, + icon = { Icon(painter = painterResource(R.drawable.bedtime), contentDescription = null) }, + title = { Text(stringResource(R.string.sleep_timer)) }, + confirmButton = { + TextButton( + onClick = { + showSleepTimerDialog = false + playerConnection.service.setSleepTimer(sleepTimerValue.roundToInt()) + } + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton( + onClick = { showSleepTimerDialog = false } + ) { + Text(stringResource(android.R.string.cancel)) + } + }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = pluralStringResource(R.plurals.minute, sleepTimerValue.roundToInt(), sleepTimerValue.roundToInt()), + style = MaterialTheme.typography.bodyLarge + ) + + Slider( + value = sleepTimerValue, + onValueChange = { sleepTimerValue = it }, + valueRange = 5f..120f, + steps = (120 - 5) / 5 - 1, + ) + + OutlinedButton( + onClick = { + showSleepTimerDialog = false + playerConnection.service.setSleepTimer(-1) + } + ) { + Text(stringResource(R.string.end_of_song)) + } + } + } + ) + } + + var showDetailsDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showDetailsDialog) { + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { showDetailsDialog = false }, + icon = { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null + ) + }, + confirmButton = { + TextButton( + onClick = { showDetailsDialog = false } + ) { + Text(stringResource(android.R.string.ok)) + } + }, + text = { + Column( + modifier = Modifier + .sizeIn(minWidth = 280.dp, maxWidth = 560.dp) + .verticalScroll(rememberScrollState()) + ) { + listOf( + stringResource(R.string.song_title) to mediaMetadata?.title, + stringResource(R.string.song_artists) to mediaMetadata?.artists?.joinToString { it.name }, + stringResource(R.string.media_id) to mediaMetadata?.id, + "Itag" to currentFormat?.itag?.toString(), + stringResource(R.string.mime_type) to currentFormat?.mimeType, + stringResource(R.string.codecs) to currentFormat?.codecs, + stringResource(R.string.bitrate) to currentFormat?.bitrate?.let { "${it / 1000} Kbps" }, + stringResource(R.string.sample_rate) to currentFormat?.sampleRate?.let { "$it Hz" }, + stringResource(R.string.loudness) to currentFormat?.loudnessDb?.let { "$it dB" }, + stringResource(R.string.volume) to "${(playerConnection.player.volume * 100).toInt()}%", + stringResource(R.string.file_size) to currentFormat?.contentLength?.let { Formatter.formatShortFileSize(context, it) } + ).forEach { (label, text) -> + val displayText = text ?: stringResource(R.string.unknown) + Text( + text = label, + style = MaterialTheme.typography.labelMedium + ) + Text( + text = displayText, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { + clipboardManager.setText(AnnotatedString(displayText)) + Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() + } + ) + ) + Spacer(Modifier.height(8.dp)) + } + } + } + ) + } + + BottomSheet( + state = state, + backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation), + modifier = modifier, + collapsedContent = { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + ) { + IconButton(onClick = { state.expandSoft() }) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null + ) + } + IconButton(onClick = { showLyrics = !showLyrics }) { + Icon( + painter = painterResource(R.drawable.lyrics), + contentDescription = null, + modifier = Modifier.alpha(if (showLyrics) 1f else 0.5f) + ) + } + AnimatedContent( + targetState = sleepTimerEnabled + ) { sleepTimerEnabled -> + if (sleepTimerEnabled) { + Text( + text = makeTimeString(sleepTimerTimeLeft), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable(onClick = playerConnection.service::clearSleepTimer) + .padding(8.dp) + ) + } else { + IconButton(onClick = { showSleepTimerDialog = true }) { + Icon( + painter = painterResource(R.drawable.bedtime), + contentDescription = null + ) + } + } + } + IconButton(onClick = playerConnection::toggleLibrary) { + Icon( + painter = painterResource(if (currentSong?.song?.inLibrary != null) R.drawable.library_add_check else R.drawable.library_add), + contentDescription = null + ) + } + IconButton( + onClick = { + menuState.show { + PlayerMenu( + mediaMetadata = mediaMetadata, + navController = navController, + playerBottomSheetState = playerBottomSheetState, + playerConnection = playerConnection, + onShowDetailsDialog = { showDetailsDialog = true }, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_horiz), + contentDescription = null + ) + } + } + } + ) { + val queueTitle by playerConnection.queueTitle.collectAsState() + val queueWindows by playerConnection.queueWindows.collectAsState() + val queueLength = remember(queueWindows) { + queueWindows.sumOf { it.mediaItem.metadata!!.duration } + } + + val coroutineScope = rememberCoroutineScope() + val reorderingState = rememberReorderingState( + lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = currentWindowIndex), + key = queueWindows, + onDragEnd = { currentIndex, newIndex -> + if (!playerConnection.player.shuffleModeEnabled) { + playerConnection.player.moveMediaItem(currentIndex, newIndex) + } else { + playerConnection.player.setShuffleOrder( + DefaultShuffleOrder( + queueWindows.map { it.firstPeriodIndex }.toMutableList().move(currentIndex, newIndex).toIntArray(), + System.currentTimeMillis() + ) + ) + } + }, + extraItemCount = 0 + ) + + ReorderingLazyColumn( + reorderingState = reorderingState, + contentPadding = WindowInsets.systemBars + .add( + WindowInsets( + top = ListItemHeight, + bottom = ListItemHeight + ) + ) + .asPaddingValues(), + modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection) + ) { + itemsIndexed( + items = queueWindows, + key = { _, item -> item.uid.hashCode() } + ) { index, window -> + val currentItem by rememberUpdatedState(window) + val dismissState = rememberDismissState( + positionalThreshold = { totalDistance -> + totalDistance + }, + confirmValueChange = { dismissValue -> + if (dismissValue == DismissValue.DismissedToEnd || dismissValue == DismissValue.DismissedToStart) { + playerConnection.player.removeMediaItem(currentItem.firstPeriodIndex) + } + true + } + ) + SwipeToDismiss( + state = dismissState, + background = {}, + dismissContent = { + MediaMetadataListItem( + mediaMetadata = window.mediaItem.metadata!!, + isActive = index == currentWindowIndex, + isPlaying = isPlaying, + trailingContent = { + Icon( + painter = painterResource(R.drawable.drag_handle), + contentDescription = null, + modifier = Modifier + .reorder( + reorderingState = reorderingState, + index = index + ) + .clickable( + enabled = false, + onClick = {} + ) + .padding(8.dp) + ) + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + coroutineScope.launch(Dispatchers.Main) { + if (index == currentWindowIndex) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.player.seekToDefaultPosition(window.firstPeriodIndex) + playerConnection.player.playWhenReady = true + } + } + } + .draggedItem( + reorderingState = reorderingState, + index = index + ) + ) + } + ) + } + } + + Box( + modifier = Modifier + .background( + MaterialTheme.colorScheme + .surfaceColorAtElevation(NavigationBarDefaults.Elevation) + .copy(alpha = 0.95f) + ) + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 12.dp) + ) { + Text( + text = queueTitle.orEmpty(), + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.End + ) { + Text( + text = pluralStringResource(R.plurals.n_song, queueWindows.size, queueWindows.size), + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = makeTimeString(queueLength * 1000L), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + val shuffleModeEnabled by playerConnection.shuffleModeEnabled.collectAsState() + + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.8f)) + .fillMaxWidth() + .height( + ListItemHeight + + WindowInsets.systemBars + .asPaddingValues() + .calculateBottomPadding() + ) + .align(Alignment.BottomCenter) + .clickable { + state.collapseSoft() + } + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + .padding(12.dp) + ) { + IconButton( + modifier = Modifier.align(Alignment.CenterStart), + onClick = { + reorderingState.coroutineScope.launch { + reorderingState.lazyListState.animateScrollToItem( + if (playerConnection.player.shuffleModeEnabled) playerConnection.player.currentMediaItemIndex else 0 + ) + }.invokeOnCompletion { + playerConnection.player.shuffleModeEnabled = !playerConnection.player.shuffleModeEnabled + } + } + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.alpha(if (shuffleModeEnabled) 1f else 0.5f) + ) + } + + Icon( + painter = painterResource(R.drawable.expand_more), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + } + } +} + +@Composable +fun PlayerMenu( + mediaMetadata: MediaMetadata?, + navController: NavController, + playerBottomSheetState: BottomSheetState, + playerConnection: PlayerConnection, + onShowDetailsDialog: () -> Unit, + onDismiss: () -> Unit, +) { + mediaMetadata ?: return + val context = LocalContext.current + val database = LocalDatabase.current + val playerVolume = playerConnection.service.playerVolume.collectAsState() + val activityResultLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } + + val download by LocalDownloadUtil.current.getDownload(mediaMetadata.id).collectAsState(initial = null) + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onAdd = { playlist -> + database.transaction { + insert(mediaMetadata) + insert( + PlaylistSongMap( + songId = mediaMetadata.id, + playlistId = playlist.id, + position = playlist.songCount + ) + ) + } + }, + onDismiss = { + showChoosePlaylistDialog = false + } + ) + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items(mediaMetadata.artists) { artist -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + playerBottomSheetState.collapseSoft() + onDismiss() + } + .padding(horizontal = 24.dp), + ) { + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 24.dp, bottom = 12.dp) + ) { + Icon( + painter = painterResource(R.drawable.volume_up), + contentDescription = null, + modifier = Modifier.size(28.dp) + ) + + BigSeekBar( + progressProvider = playerVolume::value, + onProgressChange = { playerConnection.service.playerVolume.value = it }, + modifier = Modifier.weight(1f) + ) + } + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + GridMenuItem( + icon = R.drawable.radio, + title = R.string.start_radio + ) { + playerConnection.service.startRadioSeamlessly() + onDismiss() + } + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + DownloadGridMenu( + state = download?.state, + onDownload = { + database.transaction { + insert(mediaMetadata) + } + val downloadRequest = DownloadRequest.Builder(mediaMetadata.id, mediaMetadata.id.toUri()) + .setCustomCacheKey(mediaMetadata.id) + .setData(mediaMetadata.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + }, + onRemoveDownload = { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + mediaMetadata.id, + false + ) + } + ) + GridMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist + ) { + if (mediaMetadata.artists.size == 1) { + navController.navigate("artist/${mediaMetadata.artists[0].id}") + playerBottomSheetState.collapseSoft() + onDismiss() + } else { + showSelectArtistDialog = true + } + } + if (mediaMetadata.album != null) { + GridMenuItem( + icon = R.drawable.album, + title = R.string.view_album + ) { + navController.navigate("album/${mediaMetadata.album.id}") + playerBottomSheetState.collapseSoft() + onDismiss() + } + } + GridMenuItem( + icon = R.drawable.share, + title = R.string.share + ) { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaMetadata.id}") + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + GridMenuItem( + icon = R.drawable.info, + title = R.string.details + ) { + onShowDetailsDialog() + onDismiss() + } + GridMenuItem( + icon = R.drawable.equalizer, + title = R.string.equalizer + ) { + val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, playerConnection.player.audioSessionId) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) + putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + } + if (intent.resolveActivity(context.packageManager) != null) { + activityResultLauncher.launch(intent) + } + onDismiss() + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Thumbnail.kt b/app/src/main/java/com/zionhuang/music/ui/player/Thumbnail.kt new file mode 100644 index 000000000..07d761da2 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/player/Thumbnail.kt @@ -0,0 +1,145 @@ +package com.zionhuang.music.ui.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.constants.ShowLyricsKey +import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.extensions.metadata +import com.zionhuang.music.ui.component.Lyrics +import com.zionhuang.music.ui.utils.HorizontalPager +import com.zionhuang.music.ui.utils.SnapLayoutInfoProvider +import com.zionhuang.music.utils.rememberPreference +import kotlinx.coroutines.flow.drop + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Thumbnail( + sliderPositionProvider: () -> Long?, + modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val currentView = LocalView.current + + val windows by playerConnection.queueWindows.collectAsState() + val currentWindowIndex by playerConnection.currentWindowIndex.collectAsState() + val error by playerConnection.error.collectAsState() + + val showLyrics by rememberPreference(ShowLyricsKey, false) + + val pagerState = rememberPagerState( + initialPage = currentWindowIndex.takeIf { it != -1 } ?: 0 + ) + + val snapLayoutInfoProvider = remember(pagerState) { + SnapLayoutInfoProvider( + pagerState = pagerState, + positionInLayout = { _, _ -> 0f } + ) + } + + LaunchedEffect(pagerState, currentWindowIndex) { + if (windows.isNotEmpty()) { + try { + pagerState.animateScrollToPage(currentWindowIndex) + } catch (_: Exception) { + } + } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.drop(1).collect { index -> + if (!pagerState.isScrollInProgress && index != currentWindowIndex && windows.isNotEmpty()) { + playerConnection.player.seekToDefaultPosition(windows[index].firstPeriodIndex) + } + } + } + + DisposableEffect(showLyrics) { + currentView.keepScreenOn = showLyrics + onDispose { + currentView.keepScreenOn = false + } + } + + Box(modifier = modifier) { + AnimatedVisibility( + visible = !showLyrics && error == null, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + ) { + HorizontalPager( + state = pagerState, + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + items = windows, + key = { it.uid.hashCode() }, + beyondBoundsPageCount = 2 + ) { window -> + Box(Modifier.fillMaxSize()) { + AsyncImage( + model = window.mediaItem.metadata?.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .align(Alignment.Center) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { offset -> + if (offset.x < size.width / 2) { + playerConnection.player.seekBack() + } else { + playerConnection.player.seekForward() + } + } + ) + } + ) + } + } + } + + AnimatedVisibility( + visible = showLyrics && error == null, + enter = fadeIn(), + exit = fadeOut() + ) { + Lyrics(sliderPositionProvider = sliderPositionProvider) + } + + AnimatedVisibility( + visible = error != null, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .padding(32.dp) + .align(Alignment.Center) + ) { + error?.let { error -> + PlaybackError( + error = error, + retry = playerConnection.player::prepare + ) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt new file mode 100644 index 000000000..57244fae3 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt @@ -0,0 +1,747 @@ +package com.zionhuang.music.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.pages.AlbumPage +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.AlbumThumbnailSize +import com.zionhuang.music.constants.CONTENT_TYPE_SONG +import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.db.entities.Album +import com.zionhuang.music.db.entities.AlbumWithSongs +import com.zionhuang.music.db.entities.Song +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.playback.ExoDownloadService +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.AutoResizeText +import com.zionhuang.music.ui.component.FontSizeRange +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.component.YouTubeListItem +import com.zionhuang.music.ui.component.shimmer.ButtonPlaceholder +import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder +import com.zionhuang.music.ui.component.shimmer.ShimmerHost +import com.zionhuang.music.ui.component.shimmer.TextPlaceholder +import com.zionhuang.music.ui.menu.AlbumMenu +import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.ui.menu.YouTubeAlbumMenu +import com.zionhuang.music.ui.menu.YouTubeSongMenu +import com.zionhuang.music.viewmodels.AlbumViewModel +import com.zionhuang.music.viewmodels.AlbumViewState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AlbumScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: AlbumViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val viewState by viewModel.viewState.collectAsState() + val inLibrary by viewModel.inLibrary.collectAsState() + + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(viewState) { + val songs = when (val state = viewState) { + is AlbumViewState.Local -> state.albumWithSongs.songs.map { it.id } + is AlbumViewState.Remote -> state.albumPage.songs.map { it.id } + else -> return@LaunchedEffect + } + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it]?.state == Download.STATE_COMPLETED }) + Download.STATE_COMPLETED + else if (songs.all { + downloads[it]?.state == Download.STATE_QUEUED + || downloads[it]?.state == Download.STATE_DOWNLOADING + || downloads[it]?.state == Download.STATE_COMPLETED + }) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + viewState.let { viewState -> + when (viewState) { + is AlbumViewState.Local -> { + item { + LocalAlbumHeader( + albumWithSongs = viewState.albumWithSongs, + inLibrary = inLibrary, + downloadState = downloadState, + onDownload = { + viewState.albumWithSongs.songs.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + onRemoveDownload = { + viewState.albumWithSongs.songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + }, + navController = navController + ) + } + + itemsIndexed( + items = viewState.albumWithSongs.songs, + key = { _, song -> song.id } + ) { index, song -> + SongListItem( + song = song, + albumIndex = index + 1, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = viewState.albumWithSongs.album.title, + items = viewState.albumWithSongs.songs.map { it.toMediaItem() }, + startIndex = index + ) + ) + } + } + ) + } + } + + is AlbumViewState.Remote -> { + item { + RemoteAlbumHeader( + albumPage = viewState.albumPage, + inLibrary = inLibrary, + downloadState = downloadState, + onDownload = { + viewState.albumPage.songs.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + onRemoveDownload = { + viewState.albumPage.songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + }, + navController = navController + ) + } + + itemsIndexed( + items = viewState.albumPage.songs, + key = { _, song -> song.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { index, song -> + YouTubeListItem( + item = song, + albumIndex = index + 1, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = viewState.albumPage.album.title, + items = viewState.albumPage.songs.map { it.toMediaItem() }, + startIndex = index + ) + ) + } + } + ) + } + } + + null -> { + item { + ShimmerHost { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer( + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .background(MaterialTheme.colorScheme.onSurface) + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder() + } + } + + Spacer(Modifier.padding(8.dp)) + + Row { + ButtonPlaceholder(Modifier.weight(1f)) + + Spacer(Modifier.width(12.dp)) + + ButtonPlaceholder(Modifier.weight(1f)) + } + } + + repeat(6) { + ListItemPlaceHolder() + } + } + } + } + } + } + } + + TopAppBar( + title = { }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} + +@Composable +fun LocalAlbumHeader( + albumWithSongs: AlbumWithSongs, + inLibrary: Boolean, + downloadState: Int, + onDownload: () -> Unit, + onRemoveDownload: () -> Unit, + navController: NavController, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val database = LocalDatabase.current + val menuState = LocalMenuState.current + + Column( + modifier = Modifier.padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = albumWithSongs.album.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = albumWithSongs.album.title, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + + val annotatedString = buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground + ).toSpanStyle() + ) { + albumWithSongs.artists.fastForEachIndexed { index, artist -> + pushStringAnnotation(artist.id, artist.name) + append(artist.name) + pop() + if (index != albumWithSongs.artists.lastIndex) { + append(", ") + } + } + } + } + ClickableText(annotatedString) { offset -> + annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> + navController.navigate("artist/${range.tag}") + } + } + + if (albumWithSongs.album.year != null) { + Text( + text = albumWithSongs.album.year.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + } + + Row { + IconButton( + onClick = { + database.query { + if (inLibrary) { + delete(albumWithSongs.album) + } else { + insert(albumWithSongs) + } + } + } + ) { + Icon( + painter = painterResource(if (inLibrary) R.drawable.library_add_check else R.drawable.library_add), + contentDescription = null + ) + } + + when (downloadState) { + Download.STATE_COMPLETED -> { + IconButton(onClick = onRemoveDownload) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null + ) + } + } + + Download.STATE_DOWNLOADING -> { + IconButton(onClick = onRemoveDownload) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp) + ) + } + } + + else -> { + IconButton(onClick = onDownload) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null + ) + } + } + } + + IconButton( + onClick = { + menuState.show { + AlbumMenu( + album = Album(albumWithSongs.album, albumWithSongs.artists), + navController = navController, + playerConnection = playerConnection, + showDeleteButton = false, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + } + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = albumWithSongs.album.title, + items = albumWithSongs.songs.map(Song::toMediaItem) + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.play) + ) + } + + OutlinedButton( + onClick = { + playerConnection.playQueue( + ListQueue( + title = albumWithSongs.album.title, + items = albumWithSongs.songs.shuffled().map(Song::toMediaItem) + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } +} + +@Composable +fun RemoteAlbumHeader( + albumPage: AlbumPage, + inLibrary: Boolean, + downloadState: Int, + onDownload: () -> Unit, + onRemoveDownload: () -> Unit, + navController: NavController, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val menuState = LocalMenuState.current + val database = LocalDatabase.current + + Column( + modifier = Modifier.padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = albumPage.album.thumbnail, + contentDescription = null, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = albumPage.album.title, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + + val annotatedString = buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground + ).toSpanStyle() + ) { + albumPage.album.artists?.fastForEachIndexed { index, artist -> + if (artist.id != null) { + pushStringAnnotation(artist.id!!, artist.name) + append(artist.name) + pop() + } else { + append(artist.name) + } + if (index != albumPage.album.artists?.lastIndex) { + append(", ") + } + } + } + } + ClickableText(annotatedString) { offset -> + annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> + navController.navigate("artist/${range.tag}") + } + } + + if (albumPage.album.year != null) { + Text( + text = albumPage.album.year.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + } + + Row { + IconButton( + onClick = { + database.query { + if (inLibrary) { + runBlocking(Dispatchers.IO) { + albumWithSongs(albumPage.album.browseId).first() + }?.let { + delete(it.album) + } + } else { + insert(albumPage) + } + } + } + ) { + Icon( + painter = painterResource(if (inLibrary) R.drawable.library_add_check else R.drawable.library_add), + contentDescription = null + ) + } + + when (downloadState) { + Download.STATE_COMPLETED -> { + IconButton(onClick = onRemoveDownload) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null + ) + } + } + + Download.STATE_DOWNLOADING -> { + IconButton(onClick = onRemoveDownload) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp) + ) + } + } + + else -> { + IconButton(onClick = onDownload) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null + ) + } + } + } + + IconButton( + onClick = { + menuState.show { + YouTubeAlbumMenu( + album = albumPage.album, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + } + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = albumPage.album.title, + items = albumPage.songs.map(SongItem::toMediaItem) + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.play) + ) + } + + OutlinedButton( + onClick = { + playerConnection.playQueue( + ListQueue( + title = albumPage.album.title, + items = albumPage.songs.shuffled().map(SongItem::toMediaItem) + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt new file mode 100644 index 000000000..65437edf3 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt @@ -0,0 +1,140 @@ +package com.zionhuang.music.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.viewmodels.DateAgo +import com.zionhuang.music.viewmodels.HistoryViewModel +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun HistoryScreen( + navController: NavController, + viewModel: HistoryViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val events by viewModel.events.collectAsState() + + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom).asPaddingValues(), + modifier = Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top)) + ) { + events.forEach { (dateAgo, events) -> + stickyHeader { + Text( + text = when (dateAgo) { + DateAgo.Today -> stringResource(R.string.today) + DateAgo.Yesterday -> stringResource(R.string.yesterday) + DateAgo.ThisWeek -> stringResource(R.string.this_week) + DateAgo.LastWeek -> stringResource(R.string.last_week) + is DateAgo.Other -> dateAgo.date.format(DateTimeFormatter.ofPattern("yyyy/MM")) + }, + style = MaterialTheme.typography.headlineMedium, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + + items( + items = events, + key = { it.event.id } + ) { event -> + SongListItem( + song = event.song, + isActive = event.song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = event.song, + event = event.event, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + if (event.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + endpoint = WatchEndpoint(videoId = event.song.id), + preloadItem = event.song.toMediaMetadata() + ) + ) + } + } + .animateItemPlacement() + ) + } + } + } + + TopAppBar( + title = { Text(stringResource(R.string.history)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/HomeScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/HomeScreen.kt new file mode 100644 index 000000000..2f2065f9e --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/HomeScreen.kt @@ -0,0 +1,318 @@ +package com.zionhuang.music.ui.screens + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeAlbumRadio +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.component.YouTubeGridItem +import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.ui.menu.YouTubeAlbumMenu +import com.zionhuang.music.ui.utils.SnapLayoutInfoProvider +import com.zionhuang.music.viewmodels.HomeViewModel +import kotlin.random.Random + +@Suppress("DEPRECATION") +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HomeScreen( + navController: NavController, + viewModel: HomeViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val quickPicks by viewModel.quickPicks.collectAsState() + val newReleaseAlbums by viewModel.newReleaseAlbums.collectAsState() + + val isRefreshing by viewModel.isRefreshing.collectAsState() + val mostPlayedLazyGridState = rememberLazyGridState() + + SwipeRefresh( + state = rememberSwipeRefreshState(isRefreshing), + onRefresh = viewModel::refresh, + indicatorPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize() + ) { + val horizontalLazyGridItemWidthFactor = if (maxWidth * 0.475f >= 320.dp) 0.475f else 0.9f + val horizontalLazyGridItemWidth = maxWidth * horizontalLazyGridItemWidthFactor + val snapLayoutInfoProvider = remember(mostPlayedLazyGridState) { + SnapLayoutInfoProvider( + lazyGridState = mostPlayedLazyGridState, + positionInLayout = { layoutSize, itemSize -> + (layoutSize * horizontalLazyGridItemWidthFactor / 2f - itemSize / 2f) + } + ) + } + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateTopPadding())) + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + NavigationTile( + title = stringResource(R.string.history), + icon = R.drawable.history, + onClick = { navController.navigate("history") }, + modifier = Modifier.weight(1f) + ) + NavigationTile( + title = stringResource(R.string.stats), + icon = R.drawable.trending_up, + onClick = { navController.navigate("stats") }, + modifier = Modifier.weight(1f) + ) + NavigationTile( + title = stringResource(R.string.settings), + icon = R.drawable.settings, + onClick = { navController.navigate("settings") }, + modifier = Modifier.weight(1f) + ) + } + + Text( + text = stringResource(R.string.quick_picks), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(12.dp) + ) + + quickPicks?.let { quickPicks -> + if (quickPicks.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight * 4) + ) { + Text( + text = stringResource(R.string.quick_picks_empty), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.align(Alignment.Center) + ) + } + } else { + LazyHorizontalGrid( + state = mostPlayedLazyGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight * 4) + ) { + items( + items = quickPicks, + key = { it.id } + ) { originalSong -> + val song by database.song(originalSong.id).collectAsState(initial = originalSong) + + SongListItem( + song = song!!, + showInLibraryIcon = true, + isActive = song!!.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song!!, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .width(horizontalLazyGridItemWidth) + .clickable { + if (song!!.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song!!.id), song!!.toMediaMetadata())) + } + } + ) + } + } + } + } + + if (newReleaseAlbums.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .clickable { + navController.navigate("new_release") + } + .padding(12.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.new_release_albums), + style = MaterialTheme.typography.headlineSmall + ) + } + + Icon( + painter = painterResource(R.drawable.navigate_next), + contentDescription = null + ) + } + + LazyRow( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues() + ) { + items( + items = newReleaseAlbums, + key = { it.id } + ) { album -> + YouTubeGridItem( + item = album, + isActive = mediaMetadata?.album?.id == album.id, + isPlaying = isPlaying, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + menuState.show { + YouTubeAlbumMenu( + album = album, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItemPlacement() + ) + } + } + } + + Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateBottomPadding())) + } + + if (!quickPicks.isNullOrEmpty() || newReleaseAlbums.isNotEmpty()) { + FloatingActionButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + .padding(16.dp), + onClick = { + if (Random.nextBoolean() && !quickPicks.isNullOrEmpty()) { + val song = quickPicks!!.random() + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } else if (newReleaseAlbums.isNotEmpty()) { + val album = newReleaseAlbums.random() + playerConnection.playQueue(YouTubeAlbumRadio(album.playlistId)) + } + }) { + Icon( + painter = painterResource(R.drawable.casino), + contentDescription = null + ) + } + } + } + } +} + +@Composable +fun NavigationTile( + title: String, + @DrawableRes icon: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + .clickable(enabled = enabled, onClick = onClick) + .padding(12.dp) + .alpha(if (enabled) 1f else 0.5f) + ) { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + + Spacer(Modifier.height(6.dp)) + + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt new file mode 100644 index 000000000..14eb1408e --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt @@ -0,0 +1,102 @@ +package com.zionhuang.music.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.GridThumbnailHeight +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.YouTubeGridItem +import com.zionhuang.music.ui.component.shimmer.GridItemPlaceHolder +import com.zionhuang.music.ui.component.shimmer.ShimmerHost +import com.zionhuang.music.ui.menu.YouTubeAlbumMenu +import com.zionhuang.music.viewmodels.NewReleaseViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun NewReleaseScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: NewReleaseViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val newReleaseAlbums by viewModel.newReleaseAlbums.collectAsState() + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = newReleaseAlbums, + key = { it.id } + ) { album -> + YouTubeGridItem( + item = album, + isActive = mediaMetadata?.album?.id == album.id, + isPlaying = isPlaying, + fillMaxWidth = true, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + menuState.show { + YouTubeAlbumMenu( + album = album, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + + if (newReleaseAlbums.isEmpty()) { + items(8) { + ShimmerHost { + GridItemPlaceHolder(fillMaxWidth = true) + } + } + } + } + + TopAppBar( + title = { Text(stringResource(R.string.new_release_albums)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt b/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt new file mode 100644 index 000000000..3d2aea354 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt @@ -0,0 +1,19 @@ +package com.zionhuang.music.ui.screens + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import com.zionhuang.music.R + +@Immutable +sealed class Screens( + @StringRes val titleId: Int, + @DrawableRes val iconId: Int, + val route: String, +) { + object Home : Screens(R.string.home, R.drawable.home, "home") + object Songs : Screens(R.string.songs, R.drawable.music_note, "songs") + object Artists : Screens(R.string.artists, R.drawable.artist, "artists") + object Albums : Screens(R.string.albums, R.drawable.album, "albums") + object Playlists : Screens(R.string.playlists, R.drawable.queue_music, "playlists") +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt new file mode 100644 index 000000000..46c6143ac --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt @@ -0,0 +1,155 @@ +package com.zionhuang.music.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.ArtistListItem +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.viewmodels.StatsViewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun StatsScreen( + navController: NavController, + viewModel: StatsViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val mostPlayedSongs by viewModel.mostPlayedSongs.collectAsState() + val mostPlayedArtists by viewModel.mostPlayedArtists.collectAsState() + + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom).asPaddingValues(), + modifier = Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top)) + ) { + item { + Text( + text = stringResource(R.string.most_played_songs), + style = MaterialTheme.typography.headlineMedium, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + items( + items = mostPlayedSongs, + key = { it.id } + ) { song -> + SongListItem( + song = song, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + endpoint = WatchEndpoint(song.id), + preloadItem = song.toMediaMetadata() + ) + ) + } + } + .animateItemPlacement() + ) + } + item { + Text( + text = stringResource(R.string.most_played_artists), + style = MaterialTheme.typography.headlineMedium, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + items( + items = mostPlayedArtists, + key = { it.id } + ) { artist -> + ArtistListItem( + artist = artist, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate("artist/${artist.id}") + } + .animateItemPlacement() + ) + } + } + + TopAppBar( + title = { Text(stringResource(R.string.stats)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt new file mode 100644 index 000000000..78d8b637d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt @@ -0,0 +1,256 @@ +package com.zionhuang.music.ui.screens.artist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.models.ArtistItem +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.GridThumbnailHeight +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.YouTubeGridItem +import com.zionhuang.music.ui.component.YouTubeListItem +import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder +import com.zionhuang.music.ui.component.shimmer.ShimmerHost +import com.zionhuang.music.ui.menu.YouTubeAlbumMenu +import com.zionhuang.music.ui.menu.YouTubeArtistMenu +import com.zionhuang.music.ui.menu.YouTubePlaylistMenu +import com.zionhuang.music.ui.menu.YouTubeSongMenu +import com.zionhuang.music.viewmodels.ArtistItemsViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ArtistItemsScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: ArtistItemsViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val lazyListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + val title by viewModel.title.collectAsState() + val itemsPage by viewModel.itemsPage.collectAsState() + + LaunchedEffect(lazyListState) { + snapshotFlow { + lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + }.collect { shouldLoadMore -> + if (!shouldLoadMore) return@collect + viewModel.loadMore() + } + } + + if (itemsPage == null) { + ShimmerHost( + modifier = Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + ) { + repeat(8) { + ListItemPlaceHolder() + } + } + } + + if (itemsPage?.items?.firstOrNull() is SongItem) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = itemsPage?.items.orEmpty(), + key = { it.id } + ) { item -> + YouTubeListItem( + item = item, + isActive = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is AlbumItem -> YouTubeAlbumMenu( + album = item, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is ArtistItem -> YouTubeArtistMenu( + artist = item, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is PlaylistItem -> YouTubePlaylistMenu( + playlist = item, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + when (item) { + is SongItem -> { + if (item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + } + } + + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") + } + } + ) + } + + if (itemsPage?.continuation != null) { + item(key = "loading") { + ShimmerHost { + repeat(3) { + ListItemPlaceHolder() + } + } + } + } + } + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = itemsPage?.items.orEmpty(), + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + isActive = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + fillMaxWidth = true, + modifier = Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") + } + }, + onLongClick = { + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is AlbumItem -> YouTubeAlbumMenu( + album = item, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is ArtistItem -> YouTubeArtistMenu( + artist = item, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is PlaylistItem -> YouTubePlaylistMenu( + playlist = item, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + } + ) + ) + } + } + } + + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt new file mode 100644 index 000000000..7efc0238e --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt @@ -0,0 +1,460 @@ +package com.zionhuang.music.ui.screens.artist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEach +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.valentinilk.shimmer.shimmer +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.models.ArtistItem +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.AppBarHeight +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.AutoResizeText +import com.zionhuang.music.ui.component.FontSizeRange +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.component.YouTubeGridItem +import com.zionhuang.music.ui.component.YouTubeListItem +import com.zionhuang.music.ui.component.shimmer.ButtonPlaceholder +import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder +import com.zionhuang.music.ui.component.shimmer.ShimmerHost +import com.zionhuang.music.ui.component.shimmer.TextPlaceholder +import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.ui.menu.YouTubeAlbumMenu +import com.zionhuang.music.ui.menu.YouTubeArtistMenu +import com.zionhuang.music.ui.menu.YouTubePlaylistMenu +import com.zionhuang.music.ui.menu.YouTubeSongMenu +import com.zionhuang.music.ui.utils.fadingEdge +import com.zionhuang.music.ui.utils.resize +import com.zionhuang.music.viewmodels.ArtistViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ArtistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: ArtistViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val coroutineScope = rememberCoroutineScope() + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val artistPage = viewModel.artistPage + val librarySongs by viewModel.librarySongs.collectAsState() + + val lazyListState = rememberLazyListState() + + val transparentAppBar by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex == 0 + } + } + + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .add(WindowInsets(top = -WindowInsets.systemBars.asPaddingValues().calculateTopPadding() - AppBarHeight)) + .asPaddingValues() + ) { + artistPage.let { + if (artistPage != null) { + item(key = "header") { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(4f / 3) + ) { + AsyncImage( + model = artistPage.artist.thumbnail.resize(1200, 900), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .fadingEdge( + top = WindowInsets.systemBars + .asPaddingValues() + .calculateTopPadding() + AppBarHeight, + bottom = 64.dp + ) + ) + AutoResizeText( + text = artistPage.artist.title, + style = MaterialTheme.typography.displayLarge, + fontSizeRange = FontSizeRange(32.sp, 58.sp), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 48.dp) + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp) + ) { + artistPage.artist.shuffleEndpoint?.let { shuffleEndpoint -> + Button( + onClick = { + playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.shuffle) + ) + } + } + + artistPage.artist.radioEndpoint?.let { radioEndpoint -> + OutlinedButton( + onClick = { + playerConnection.playQueue(YouTubeQueue(radioEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.radio)) + } + } + } + } + } + + if (librarySongs.isNotEmpty()) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate("artist/${viewModel.artistId}/songs") + } + .padding(12.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.from_your_library), + style = MaterialTheme.typography.headlineMedium + ) + } + Icon( + painter = painterResource(R.drawable.navigate_next), + contentDescription = null + ) + } + } + + items( + items = librarySongs, + key = { "local_${it.id}" } + ) { song -> + SongListItem( + song = song, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + } + .animateItemPlacement() + ) + } + } + + artistPage.sections.fastForEach { section -> + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = section.moreEndpoint != null) { + navController.navigate("artist/${viewModel.artistId}/items?browseId=${section.moreEndpoint?.browseId}?params=${section.moreEndpoint?.params}") + } + .padding(12.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = section.title, + style = MaterialTheme.typography.headlineMedium + ) + } + if (section.moreEndpoint != null) { + Icon( + painter = painterResource(R.drawable.navigate_next), + contentDescription = null + ) + } + } + } + + if ((section.items.firstOrNull() as? SongItem)?.album != null) { + items( + items = section.items, + key = { it.id } + ) { song -> + YouTubeListItem( + item = song as SongItem, + isActive = mediaMetadata?.id == song.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + } + .animateItemPlacement() + ) + } + } else { + item { + LazyRow { + items( + items = section.items, + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + isActive = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + modifier = Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") + } + }, + onLongClick = { + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is AlbumItem -> YouTubeAlbumMenu( + album = item, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is ArtistItem -> YouTubeArtistMenu( + artist = item, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is PlaylistItem -> YouTubePlaylistMenu( + playlist = item, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + } + ) + .animateItemPlacement() + ) + } + } + } + } + } + } else { + item(key = "shimmer") { + ShimmerHost { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(4f / 3) + ) { + Spacer( + modifier = Modifier + .shimmer() + .background(MaterialTheme.colorScheme.onSurface) + .fadingEdge( + top = WindowInsets.systemBars + .asPaddingValues() + .calculateTopPadding() + AppBarHeight, + bottom = 108.dp + ) + ) + TextPlaceholder( + height = 56.dp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 48.dp) + ) + } + + Row( + modifier = Modifier.padding(12.dp) + ) { + ButtonPlaceholder(Modifier.weight(1f)) + + Spacer(Modifier.width(12.dp)) + + ButtonPlaceholder(Modifier.weight(1f)) + } + + repeat(6) { + ListItemPlaceHolder() + } + } + } + } + } + } + + TopAppBar( + title = { if (!transparentAppBar) Text(artistPage?.artist?.title.orEmpty()) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior, + colors = if (transparentAppBar) { + TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) + } else { + TopAppBarDefaults.topAppBarColors() + } + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt new file mode 100644 index 000000000..583f2aea8 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt @@ -0,0 +1,194 @@ +package com.zionhuang.music.ui.screens.artist + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.ArtistSongSortDescendingKey +import com.zionhuang.music.constants.ArtistSongSortType +import com.zionhuang.music.constants.ArtistSongSortTypeKey +import com.zionhuang.music.constants.CONTENT_TYPE_HEADER +import com.zionhuang.music.constants.CONTENT_TYPE_SONG +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.component.SortHeader +import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.ui.utils.isScrollingUp +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference +import com.zionhuang.music.viewmodels.ArtistSongsViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ArtistSongsScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: ArtistSongsViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val (sortType, onSortTypeChange) = rememberEnumPreference(ArtistSongSortTypeKey, ArtistSongSortType.CREATE_DATE) + val (sortDescending, onSortDescendingChange) = rememberPreference(ArtistSongSortDescendingKey, true) + + val artist by viewModel.artist.collectAsState() + val songs by viewModel.songs.collectAsState() + + val lazyListState = rememberLazyListState() + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + ArtistSongSortType.CREATE_DATE -> R.string.sort_by_create_date + ArtistSongSortType.NAME -> R.string.sort_by_name + } + }, + trailingText = pluralStringResource(R.plurals.n_song, songs.size, songs.size) + ) + } + + itemsIndexed( + items = songs, + key = { _, item -> item.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { index, song -> + SongListItem( + song = song, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.map { it.toMediaItem() }, + startIndex = index + ) + ) + } + } + .animateItemPlacement() + ) + } + } + + TopAppBar( + title = { Text(artist?.name.orEmpty()) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + + AnimatedVisibility( + visible = lazyListState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = { + playerConnection.playQueue( + ListQueue( + title = artist?.name, + items = songs.shuffled().map { it.toMediaItem() }, + ) + ) + } + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryAlbumsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryAlbumsScreen.kt new file mode 100644 index 000000000..66a385115 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryAlbumsScreen.kt @@ -0,0 +1,116 @@ +package com.zionhuang.music.ui.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.* +import com.zionhuang.music.ui.component.AlbumListItem +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.SortHeader +import com.zionhuang.music.ui.menu.AlbumMenu +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference +import com.zionhuang.music.viewmodels.LibraryAlbumsViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryAlbumsScreen( + navController: NavController, + viewModel: LibraryAlbumsViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val (sortType, onSortTypeChange) = rememberEnumPreference(AlbumSortTypeKey, AlbumSortType.CREATE_DATE) + val (sortDescending, onSortDescendingChange) = rememberPreference(AlbumSortDescendingKey, true) + + val albums by viewModel.allAlbums.collectAsState() + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + AlbumSortType.CREATE_DATE -> R.string.sort_by_create_date + AlbumSortType.NAME -> R.string.sort_by_name + AlbumSortType.ARTIST -> R.string.sort_by_artist + AlbumSortType.YEAR -> R.string.sort_by_year + AlbumSortType.SONG_COUNT -> R.string.sort_by_song_count + AlbumSortType.LENGTH -> R.string.sort_by_length + } + }, + trailingText = pluralStringResource(R.plurals.n_album, albums.size, albums.size) + ) + } + + items( + items = albums, + key = { it.id }, + contentType = { CONTENT_TYPE_ALBUM } + ) { album -> + AlbumListItem( + album = album, + isActive = album.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + AlbumMenu( + album = album, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + navController.navigate("album/${album.id}") + } + .animateItemPlacement() + ) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryArtistsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryArtistsScreen.kt new file mode 100644 index 000000000..5b3eed2e1 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryArtistsScreen.kt @@ -0,0 +1,81 @@ +package com.zionhuang.music.ui.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.R +import com.zionhuang.music.constants.* +import com.zionhuang.music.ui.component.ArtistListItem +import com.zionhuang.music.ui.component.SortHeader +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference +import com.zionhuang.music.viewmodels.LibraryArtistsViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryArtistsScreen( + navController: NavController, + viewModel: LibraryArtistsViewModel = hiltViewModel(), +) { + val (sortType, onSortTypeChange) = rememberEnumPreference(ArtistSortTypeKey, ArtistSortType.CREATE_DATE) + val (sortDescending, onSortDescendingChange) = rememberPreference(ArtistSortDescendingKey, true) + + val artists by viewModel.allArtists.collectAsState() + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + ArtistSortType.CREATE_DATE -> R.string.sort_by_create_date + ArtistSortType.NAME -> R.string.sort_by_name + ArtistSortType.SONG_COUNT -> R.string.sort_by_song_count + } + }, + trailingText = pluralStringResource(R.plurals.n_artist, artists.size, artists.size) + ) + } + + items( + items = artists, + key = { it.id }, + contentType = { CONTENT_TYPE_ARTIST } + ) { artist -> + ArtistListItem( + artist = artist, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate("artist/${artist.id}") + } + .animateItemPlacement() + ) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryPlaylistsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryPlaylistsScreen.kt new file mode 100644 index 000000000..65942a76b --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryPlaylistsScreen.kt @@ -0,0 +1,233 @@ +package com.zionhuang.music.ui.screens.library + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.R +import com.zionhuang.music.constants.CONTENT_TYPE_HEADER +import com.zionhuang.music.constants.CONTENT_TYPE_PLAYLIST +import com.zionhuang.music.constants.ListThumbnailSize +import com.zionhuang.music.constants.PlaylistSortDescendingKey +import com.zionhuang.music.constants.PlaylistSortType +import com.zionhuang.music.constants.PlaylistSortTypeKey +import com.zionhuang.music.db.entities.PlaylistEntity +import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID +import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID +import com.zionhuang.music.ui.component.ListItem +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.PlaylistListItem +import com.zionhuang.music.ui.component.SortHeader +import com.zionhuang.music.ui.component.TextFieldDialog +import com.zionhuang.music.ui.menu.PlaylistMenu +import com.zionhuang.music.ui.utils.isScrollingUp +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference +import com.zionhuang.music.viewmodels.LibraryPlaylistsViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryPlaylistsScreen( + navController: NavController, + viewModel: LibraryPlaylistsViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val database = LocalDatabase.current + + val coroutineScope = rememberCoroutineScope() + + val (sortType, onSortTypeChange) = rememberEnumPreference(PlaylistSortTypeKey, PlaylistSortType.CREATE_DATE) + val (sortDescending, onSortDescendingChange) = rememberPreference(PlaylistSortDescendingKey, true) + + val likedSongCount by viewModel.likedSongCount.collectAsState() + val downloadedSongCount by viewModel.downloadedSongCount.collectAsState(0) + val playlists by viewModel.allPlaylists.collectAsState() + + val lazyListState = rememberLazyListState() + + var showAddPlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showAddPlaylistDialog) { + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.add), contentDescription = null) }, + title = { Text(text = stringResource(R.string.create_playlist)) }, + onDismiss = { showAddPlaylistDialog = false }, + onDone = { playlistName -> + database.query { + insert( + PlaylistEntity( + name = playlistName + ) + ) + } + } + ) + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + PlaylistSortType.CREATE_DATE -> R.string.sort_by_create_date + PlaylistSortType.NAME -> R.string.sort_by_name + PlaylistSortType.SONG_COUNT -> R.string.sort_by_song_count + } + }, + trailingText = pluralStringResource(R.plurals.n_playlist, playlists.size, playlists.size) + ) + } + + item( + key = LIKED_PLAYLIST_ID, + contentType = CONTENT_TYPE_PLAYLIST + ) { + ListItem( + title = stringResource(R.string.liked_songs), + subtitle = pluralStringResource(R.plurals.n_song, likedSongCount, likedSongCount), + thumbnailContent = { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(ListThumbnailSize) + ) + }, + modifier = Modifier + .clickable { + navController.navigate("local_playlist/$LIKED_PLAYLIST_ID") + } + .animateItemPlacement() + ) + } + + item( + key = DOWNLOADED_PLAYLIST_ID, + contentType = CONTENT_TYPE_PLAYLIST + ) { + ListItem( + title = stringResource(R.string.downloaded_songs), + subtitle = pluralStringResource(R.plurals.n_song, downloadedSongCount, downloadedSongCount), + thumbnailContent = { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = Modifier.size(ListThumbnailSize) + ) + }, + modifier = Modifier + .clickable { + navController.navigate("local_playlist/$DOWNLOADED_PLAYLIST_ID") + } + .animateItemPlacement() + ) + } + + items( + items = playlists, + key = { it.id }, + contentType = { CONTENT_TYPE_PLAYLIST } + ) { playlist -> + PlaylistListItem( + playlist = playlist, + trailingContent = { + IconButton( + onClick = { + menuState.show { + PlaylistMenu( + playlist = playlist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate("local_playlist/${playlist.id}") + } + .animateItemPlacement() + ) + } + } + + AnimatedVisibility( + visible = lazyListState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = { showAddPlaylistDialog = true } + ) { + Icon( + painter = painterResource(R.drawable.add), + contentDescription = null + ) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt new file mode 100644 index 000000000..51487e3d7 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt @@ -0,0 +1,165 @@ +package com.zionhuang.music.ui.screens.library + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.* +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.component.SortHeader +import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.ui.utils.isScrollingUp +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference +import com.zionhuang.music.viewmodels.LibrarySongsViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibrarySongsScreen( + navController: NavController, + viewModel: LibrarySongsViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val (sortType, onSortTypeChange) = rememberEnumPreference(SongSortTypeKey, SongSortType.CREATE_DATE) + val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) + + val songs by viewModel.allSongs.collectAsState() + + val lazyListState = rememberLazyListState() + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + SongSortType.CREATE_DATE -> R.string.sort_by_create_date + SongSortType.NAME -> R.string.sort_by_name + SongSortType.ARTIST -> R.string.sort_by_artist + } + }, + trailingText = pluralStringResource(R.plurals.n_song, songs.size, songs.size) + ) + } + + itemsIndexed( + items = songs, + key = { _, item -> item.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { index, song -> + SongListItem( + song = song, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.map { it.toMediaItem() }, + startIndex = index + ) + ) + } + } + .animateItemPlacement() + ) + } + } + + AnimatedVisibility( + visible = songs.isNotEmpty() && lazyListState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.shuffled().map { it.toMediaItem() }, + ) + ) + } + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null + ) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/BuiltInPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/BuiltInPlaylistScreen.kt new file mode 100644 index 000000000..e9f9be42c --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/BuiltInPlaylistScreen.kt @@ -0,0 +1,237 @@ +package com.zionhuang.music.ui.screens.playlist + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastSumBy +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.DownloadedSongSortDescendingKey +import com.zionhuang.music.constants.DownloadedSongSortType +import com.zionhuang.music.constants.DownloadedSongSortTypeKey +import com.zionhuang.music.constants.SongSortDescendingKey +import com.zionhuang.music.constants.SongSortType +import com.zionhuang.music.constants.SongSortTypeKey +import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID +import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.component.SortHeader +import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.ui.utils.isScrollingUp +import com.zionhuang.music.utils.joinByBullet +import com.zionhuang.music.utils.makeTimeString +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference +import com.zionhuang.music.viewmodels.BuiltInPlaylistViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun BuiltInPlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: BuiltInPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val (sortType, onSortTypeChange) = rememberEnumPreference(SongSortTypeKey, SongSortType.CREATE_DATE) + val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) + val (dlSortType, onDlSortTypeChange) = rememberEnumPreference(DownloadedSongSortTypeKey, DownloadedSongSortType.CREATE_DATE) + val (dlSortDescending, onDlSortDescendingChange) = rememberPreference(DownloadedSongSortDescendingKey, true) + + val songs by viewModel.songs.collectAsState() + val playlistLength = remember(songs) { + songs.fastSumBy { it.song.duration } + } + val playlistName = remember { + context.getString( + when (viewModel.playlistId) { + LIKED_PLAYLIST_ID -> R.string.liked_songs + DOWNLOADED_PLAYLIST_ID -> R.string.downloaded_songs + else -> error("Unknown playlist id") + } + ) + } + + val lazyListState = rememberLazyListState() + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item { + if (viewModel.playlistId == LIKED_PLAYLIST_ID) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + SongSortType.CREATE_DATE -> R.string.sort_by_create_date + SongSortType.NAME -> R.string.sort_by_name + SongSortType.ARTIST -> R.string.sort_by_artist + } + }, + trailingText = joinByBullet( + makeTimeString(playlistLength * 1000L), + pluralStringResource(R.plurals.n_song, songs.size, songs.size) + ) + ) + } else { + SortHeader( + sortType = dlSortType, + sortDescending = dlSortDescending, + onSortTypeChange = onDlSortTypeChange, + onSortDescendingChange = onDlSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + DownloadedSongSortType.CREATE_DATE -> R.string.sort_by_create_date + DownloadedSongSortType.NAME -> R.string.sort_by_name + DownloadedSongSortType.ARTIST -> R.string.sort_by_artist + } + }, + trailingText = joinByBullet( + makeTimeString(playlistLength * 1000L), + pluralStringResource(R.plurals.n_song, songs.size, songs.size) + ) + ) + } + } + + itemsIndexed( + items = songs, + key = { _, song -> song.id } + ) { index, song -> + SongListItem( + song = song, + showLikedIcon = viewModel.playlistId != LIKED_PLAYLIST_ID, + showInLibraryIcon = true, + showDownloadIcon = viewModel.playlistId != DOWNLOADED_PLAYLIST_ID, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = playlistName, + items = songs.map { it.toMediaItem() }, + startIndex = index + ) + ) + } + } + .animateItemPlacement() + ) + } + } + + TopAppBar( + title = { Text(playlistName) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + + AnimatedVisibility( + visible = songs.isNotEmpty() && lazyListState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = { + playerConnection.playQueue( + ListQueue( + title = playlistName, + items = songs.shuffled().map { it.toMediaItem() }, + ) + ) + } + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null + ) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt new file mode 100644 index 000000000..051bd7aee --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt @@ -0,0 +1,589 @@ +package com.zionhuang.music.ui.screens.playlist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DismissValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismiss +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberDismissState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.compose.ui.util.fastSumBy +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.utils.completed +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.AlbumThumbnailSize +import com.zionhuang.music.constants.PlaylistEditLockKey +import com.zionhuang.music.constants.PlaylistSongSortDescendingKey +import com.zionhuang.music.constants.PlaylistSongSortType +import com.zionhuang.music.constants.PlaylistSongSortTypeKey +import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.ExoDownloadService +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.AutoResizeText +import com.zionhuang.music.ui.component.EmptyPlaceholder +import com.zionhuang.music.ui.component.FontSizeRange +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.component.SortHeader +import com.zionhuang.music.ui.component.TextFieldDialog +import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.ui.utils.reordering.ReorderingLazyColumn +import com.zionhuang.music.ui.utils.reordering.animateItemPlacement +import com.zionhuang.music.ui.utils.reordering.draggedItem +import com.zionhuang.music.ui.utils.reordering.rememberReorderingState +import com.zionhuang.music.ui.utils.reordering.reorder +import com.zionhuang.music.utils.makeTimeString +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference +import com.zionhuang.music.viewmodels.LocalPlaylistViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun LocalPlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: LocalPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val playlist by viewModel.playlist.collectAsState() + val songs by viewModel.playlistSongs.collectAsState() + val playlistLength = remember(songs) { + songs.fastSumBy { it.song.song.duration } + } + val (sortType, onSortTypeChange) = rememberEnumPreference(PlaylistSongSortTypeKey, PlaylistSongSortType.CUSTOM) + val (sortDescending, onSortDescendingChange) = rememberPreference(PlaylistSongSortDescendingKey, true) + var locked by rememberPreference(PlaylistEditLockKey, defaultValue = false) + + val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + val snackbarHostState = remember { SnackbarHostState() } + + val showTopBarTitle by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex > 0 + } + } + + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.song.id]?.state == Download.STATE_COMPLETED }) + Download.STATE_COMPLETED + else if (songs.all { + downloads[it.song.id]?.state == Download.STATE_QUEUED + || downloads[it.song.id]?.state == Download.STATE_DOWNLOADING + || downloads[it.song.id]?.state == Download.STATE_COMPLETED + }) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + + var showEditDialog by remember { + mutableStateOf(false) + } + + if (showEditDialog) { + playlist?.playlist?.let { playlistEntity -> + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.edit), contentDescription = null) }, + title = { Text(text = stringResource(R.string.edit_playlist)) }, + onDismiss = { showEditDialog = false }, + initialTextFieldValue = TextFieldValue(playlistEntity.name, TextRange(playlistEntity.name.length)), + onDone = { name -> + database.query { + update(playlistEntity.copy(name = name)) + } + } + ) + } + } + + val reorderingState = rememberReorderingState( + lazyListState = lazyListState, + key = songs, + onDragEnd = { fromIndex, toIndex -> + database.query { + move(viewModel.playlistId, fromIndex, toIndex) + } + }, + extraItemCount = 1 + ) + + Box( + modifier = Modifier.fillMaxSize() + ) { + ReorderingLazyColumn( + reorderingState = reorderingState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + playlist?.let { playlist -> + if (playlist.songCount == 0) { + item { + EmptyPlaceholder( + icon = R.drawable.music_note, + text = stringResource(R.string.playlist_is_empty) + ) + } + } else { + item { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (playlist.thumbnails.size == 1) { + AsyncImage( + model = playlist.thumbnails[0], + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } else if (playlist.thumbnails.size > 1) { + Box( + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd + ).fastForEachIndexed { index, alignment -> + AsyncImage( + model = playlist.thumbnails.getOrNull(index), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(AlbumThumbnailSize / 2) + ) + } + } + } + + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = playlist.playlist.name, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + + Text( + text = pluralStringResource(R.plurals.n_song, playlist.songCount, playlist.songCount), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + + Text( + text = makeTimeString(playlistLength * 1000L), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + + Row { + IconButton( + onClick = { showEditDialog = true } + ) { + Icon( + painter = painterResource(R.drawable.edit), + contentDescription = null + ) + } + + if (playlist.playlist.browseId != null) { + IconButton( + onClick = { + coroutineScope.launch(Dispatchers.IO) { + val playlistPage = YouTube.playlist(playlist.playlist.browseId).completed().getOrNull() ?: return@launch + database.transaction { + clearPlaylist(playlist.id) + playlistPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { position, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = position + ) + } + .forEach(::insert) + } + snackbarHostState.showSnackbar(context.getString(R.string.playlist_synced)) + } + } + ) { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null + ) + } + } + + when (downloadState) { + Download.STATE_COMPLETED -> { + IconButton( + onClick = { + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null + ) + } + } + + Download.STATE_DOWNLOADING -> { + IconButton( + onClick = { + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp) + ) + } + } + + else -> { + IconButton( + onClick = { + songs.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.song.id, song.song.id.toUri()) + .setCustomCacheKey(song.song.id) + .setData(song.song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null + ) + } + } + } + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.map { it.song.toMediaItem() } + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.play)) + } + + OutlinedButton( + onClick = { + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.shuffled().map { it.song.toMediaItem() } + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } + } + + item { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + PlaylistSongSortType.CUSTOM -> R.string.sort_by_custom + PlaylistSongSortType.CREATE_DATE -> R.string.sort_by_create_date + PlaylistSongSortType.NAME -> R.string.sort_by_name + PlaylistSongSortType.ARTIST -> R.string.sort_by_artist + } + }, + trailingText = "", + modifier = Modifier.weight(1f) + ) + + IconButton( + onClick = { locked = !locked }, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Icon( + painter = painterResource(if (locked) R.drawable.lock else R.drawable.lock_open), + contentDescription = null + ) + } + } + } + } + } + + itemsIndexed( + items = songs, + key = { _, song -> song.map.id } + ) { index, song -> + val currentItem by rememberUpdatedState(song) + val dismissState = rememberDismissState( + positionalThreshold = { totalDistance -> + totalDistance + }, + confirmValueChange = { dismissValue -> + if (dismissValue == DismissValue.DismissedToEnd || dismissValue == DismissValue.DismissedToStart) { + database.transaction { + move(currentItem.map.playlistId, currentItem.map.position, Int.MAX_VALUE) + delete(currentItem.map.copy(position = Int.MAX_VALUE)) + } + coroutineScope.launch { + val snackbarResult = snackbarHostState.showSnackbar( + message = context.getString(R.string.removed_song_from_playlist, currentItem.song.song.title), + actionLabel = context.getString(R.string.undo) + ) + if (snackbarResult == SnackbarResult.ActionPerformed) { + database.transaction { + insert(currentItem.map.copy(position = playlistLength)) + move(currentItem.map.playlistId, playlistLength, currentItem.map.position) + } + } + } + } + true + } + ) + + val content: @Composable () -> Unit = { + SongListItem( + song = song.song, + isActive = song.song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song.song, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + + if (sortType == PlaylistSongSortType.CUSTOM && !locked) { + IconButton( + onClick = { }, + modifier = Modifier.reorder(reorderingState = reorderingState, index = index) + ) { + Icon( + painter = painterResource(R.drawable.drag_handle), + contentDescription = null + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + if (song.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = playlist!!.playlist.name, + items = songs.map { it.song.toMediaItem() }, + startIndex = index + ) + ) + } + } + .animateItemPlacement(reorderingState = reorderingState) + .draggedItem(reorderingState = reorderingState, index = index) + ) + } + + if (locked) { + content() + } else { + SwipeToDismiss( + state = dismissState, + background = {}, + dismissContent = { + content() + } + ) + } + } + } + + TopAppBar( + title = { if (showTopBarTitle) Text(playlist?.playlist?.name.orEmpty()) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt new file mode 100644 index 000000000..93202821d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -0,0 +1,367 @@ +package com.zionhuang.music.ui.screens.playlist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.AlbumThumbnailSize +import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.db.entities.PlaylistEntity +import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.AutoResizeText +import com.zionhuang.music.ui.component.FontSizeRange +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.YouTubeListItem +import com.zionhuang.music.ui.component.shimmer.ButtonPlaceholder +import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder +import com.zionhuang.music.ui.component.shimmer.ShimmerHost +import com.zionhuang.music.ui.component.shimmer.TextPlaceholder +import com.zionhuang.music.ui.menu.YouTubePlaylistMenu +import com.zionhuang.music.ui.menu.YouTubeSongMenu +import com.zionhuang.music.viewmodels.OnlinePlaylistViewModel +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun OnlinePlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: OnlinePlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val playlist by viewModel.playlist.collectAsState() + val songs by viewModel.playlistSongs.collectAsState() + + val lazyListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + val showTopBarTitle by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex > 0 + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + playlist.let { playlist -> + if (playlist != null) { + item { + Column( + modifier = Modifier.padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = playlist.thumbnail, + contentDescription = null, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = playlist.title, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + + val annotatedString = buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground + ).toSpanStyle() + ) { + if (playlist.author.id != null) { + pushStringAnnotation(playlist.author.id!!, playlist.author.name) + append(playlist.author.name) + pop() + } else { + append(playlist.author.name) + } + } + } + ClickableText(annotatedString) { offset -> + annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> + navController.navigate("artist/${range.tag}") + } + } + + playlist.songCountText?.let { songCountText -> + Text( + text = songCountText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + } + + Row { + IconButton( + onClick = { + database.transaction { + val playlistEntity = PlaylistEntity( + name = playlist.title, + browseId = playlist.id + ) + insert(playlistEntity) + songs.map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { index, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlistEntity.id, + position = index + ) + } + .forEach(::insert) + coroutineScope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.playlist_imported)) + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.input), + contentDescription = null + ) + } + + IconButton( + onClick = { + menuState.show { + YouTubePlaylistMenu( + playlist = playlist, + songs = songs, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + } + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue(YouTubeQueue(playlist.shuffleEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + + OutlinedButton( + onClick = { + playerConnection.playQueue(YouTubeQueue(playlist.radioEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.radio)) + } + } + } + } + + items( + items = songs + ) { song -> + YouTubeListItem( + item = song, + isActive = mediaMetadata?.id == song.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue(YouTubeQueue(song.endpoint ?: WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + } + .animateItemPlacement() + ) + } + } else { + item { + ShimmerHost { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer( + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .background(MaterialTheme.colorScheme.onSurface) + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder() + } + } + + Spacer(Modifier.padding(8.dp)) + + Row { + ButtonPlaceholder(Modifier.weight(1f)) + + Spacer(Modifier.width(12.dp)) + + ButtonPlaceholder(Modifier.weight(1f)) + } + } + + repeat(6) { + ListItemPlaceHolder() + } + } + } + } + } + } + + TopAppBar( + title = { if (showTopBarTitle) Text(playlist?.title.orEmpty()) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt new file mode 100644 index 000000000..f6ad2af55 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt @@ -0,0 +1,253 @@ +package com.zionhuang.music.ui.screens.search + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.CONTENT_TYPE_LIST +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.db.entities.Album +import com.zionhuang.music.db.entities.Artist +import com.zionhuang.music.db.entities.Playlist +import com.zionhuang.music.db.entities.Song +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.AlbumListItem +import com.zionhuang.music.ui.component.ArtistListItem +import com.zionhuang.music.ui.component.EmptyPlaceholder +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.PlaylistListItem +import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.viewmodels.LocalFilter +import com.zionhuang.music.viewmodels.LocalSearchViewModel +import kotlinx.coroutines.flow.drop + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@Composable +fun LocalSearchScreen( + query: String, + navController: NavController, + onDismiss: () -> Unit, + viewModel: LocalSearchViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val keyboardController = LocalSoftwareKeyboardController.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val searchFilter by viewModel.filter.collectAsState() + val result by viewModel.result.collectAsState() + + val lazyListState = rememberLazyListState() + + LaunchedEffect(Unit) { + snapshotFlow { lazyListState.firstVisibleItemScrollOffset } + .drop(1) + .collect { + keyboardController?.hide() + } + } + + LaunchedEffect(query) { + viewModel.query.value = query + } + + Column { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .horizontalScroll(rememberScrollState()) + ) { + listOf( + LocalFilter.ALL to R.string.filter_all, + LocalFilter.SONG to R.string.filter_songs, + LocalFilter.ALBUM to R.string.filter_albums, + LocalFilter.ARTIST to R.string.filter_artists, + LocalFilter.PLAYLIST to R.string.filter_playlists + ).forEach { (filter, label) -> + FilterChip( + label = { Text(stringResource(label)) }, + selected = searchFilter == filter, + colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.background), + onClick = { viewModel.filter.value = filter } + ) + } + } + + LazyColumn( + state = lazyListState, + modifier = Modifier.weight(1f) + ) { + result.map.forEach { (filter, items) -> + if (result.filter == LocalFilter.ALL) { + item( + key = filter + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight) + .clickable { viewModel.filter.value = filter } + .padding(start = 12.dp, end = 18.dp) + ) { + Text( + text = stringResource( + when (filter) { + LocalFilter.SONG -> R.string.filter_songs + LocalFilter.ALBUM -> R.string.filter_albums + LocalFilter.ARTIST -> R.string.filter_artists + LocalFilter.PLAYLIST -> R.string.filter_playlists + LocalFilter.ALL -> error("") + } + ), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f) + ) + + Icon( + painter = painterResource(R.drawable.navigate_next), + contentDescription = null + ) + } + } + } + + items( + items = items, + key = { it.id }, + contentType = { CONTENT_TYPE_LIST } + ) { item -> + when (item) { + is Song -> SongListItem( + song = item, + isActive = item.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = item, + navController = navController, + playerConnection = playerConnection + ) { + onDismiss() + menuState.dismiss() + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + if (item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + val songs = result.map + .getOrDefault(LocalFilter.SONG, emptyList()) + .filterIsInstance() + .map { it.toMediaItem() } + playerConnection.playQueue(ListQueue( + title = context.getString(R.string.queue_searched_songs), + items = songs, + startIndex = songs.indexOfFirst { it.mediaId == item.id } + )) + } + } + .animateItemPlacement() + ) + + is Album -> AlbumListItem( + album = item, + isActive = item.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + modifier = Modifier + .clickable { + onDismiss() + navController.navigate("album/${item.id}") + } + .animateItemPlacement() + ) + + is Artist -> ArtistListItem( + artist = item, + modifier = Modifier + .clickable { + onDismiss() + navController.navigate("artist/${item.id}") + } + .animateItemPlacement() + ) + + is Playlist -> PlaylistListItem( + playlist = item, + modifier = Modifier + .clickable { + onDismiss() + navController.navigate("local_playlist/${item.id}") + } + .animateItemPlacement() + ) + } + } + } + + if (result.query.isNotEmpty() && result.map.isEmpty()) { + item( + key = "no_result" + ) { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt new file mode 100644 index 000000000..f02282d0a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt @@ -0,0 +1,295 @@ +package com.zionhuang.music.ui.screens.search + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ALBUM +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ARTIST +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_COMMUNITY_PLAYLIST +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_FEATURED_PLAYLIST +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_SONG +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_VIDEO +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.models.ArtistItem +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.innertube.models.YTItem +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.AppBarHeight +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.constants.SearchFilterHeight +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.EmptyPlaceholder +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.YouTubeListItem +import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder +import com.zionhuang.music.ui.component.shimmer.ShimmerHost +import com.zionhuang.music.ui.menu.YouTubeAlbumMenu +import com.zionhuang.music.ui.menu.YouTubeArtistMenu +import com.zionhuang.music.ui.menu.YouTubePlaylistMenu +import com.zionhuang.music.ui.menu.YouTubeSongMenu +import com.zionhuang.music.viewmodels.OnlineSearchViewModel +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun OnlineSearchResult( + navController: NavController, + viewModel: OnlineSearchViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + + val searchFilter by viewModel.filter.collectAsState() + val searchSummary = viewModel.summaryPage + val itemsPage by remember(searchFilter) { + derivedStateOf { + searchFilter?.value?.let { + viewModel.viewStateMap[it] + } + } + } + + LaunchedEffect(lazyListState) { + snapshotFlow { + lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + }.collect { shouldLoadMore -> + if (!shouldLoadMore) return@collect + viewModel.loadMore() + } + } + + val ytItemContent: @Composable LazyItemScope.(YTItem) -> Unit = { item: YTItem -> + YouTubeListItem( + item = item, + isActive = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is AlbumItem -> YouTubeAlbumMenu( + album = item, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is ArtistItem -> YouTubeArtistMenu( + artist = item, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is PlaylistItem -> YouTubePlaylistMenu( + playlist = item, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + when (item) { + is SongItem -> { + if (item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + } + } + + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") + } + } + .animateItemPlacement() + ) + } + + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .add(WindowInsets(top = SearchFilterHeight)) + .asPaddingValues() + ) { + if (searchFilter == null) { + searchSummary?.summaries?.forEach { summary -> + item { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight) + .padding(12.dp) + .animateItemPlacement() + ) { + Text( + text = summary.title, + style = MaterialTheme.typography.headlineMedium, + maxLines = 1 + ) + } + } + + items( + items = summary.items, + key = { "${summary.title}/${it.id}" }, + itemContent = ytItemContent + ) + } + + if (searchSummary?.summaries?.isEmpty() == true) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found) + ) + } + } + } else { + items( + items = itemsPage?.items.orEmpty(), + key = { it.id }, + itemContent = ytItemContent + ) + + if (itemsPage?.continuation != null) { + item(key = "loading") { + ShimmerHost { + repeat(3) { + ListItemPlaceHolder() + } + } + } + } + + if (itemsPage?.items?.isEmpty() == true) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found) + ) + } + } + } + + if (searchFilter == null && searchSummary == null || searchFilter != null && itemsPage == null) { + item { + ShimmerHost { + repeat(8) { + ListItemPlaceHolder() + } + } + } + } + } + + Row( + modifier = Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) + .padding(top = AppBarHeight) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + Spacer(Modifier.width(8.dp)) + + listOf( + null to R.string.filter_all, + FILTER_SONG to R.string.filter_songs, + FILTER_VIDEO to R.string.filter_videos, + FILTER_ALBUM to R.string.filter_albums, + FILTER_ARTIST to R.string.filter_artists, + FILTER_COMMUNITY_PLAYLIST to R.string.filter_community_playlists, + FILTER_FEATURED_PLAYLIST to R.string.filter_featured_playlists + ).forEach { (filter, label) -> + FilterChip( + label = { Text(text = stringResource(label)) }, + selected = searchFilter == filter, + colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.background), + onClick = { + if (viewModel.filter.value != filter) { + viewModel.filter.value = filter + } + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + } + ) + Spacer(Modifier.width(8.dp)) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt new file mode 100644 index 000000000..2b477e9c3 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt @@ -0,0 +1,242 @@ +package com.zionhuang.music.ui.screens.search + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.models.ArtistItem +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.SuggestionItemHeight +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.SearchBarIconOffsetX +import com.zionhuang.music.ui.component.YouTubeListItem +import com.zionhuang.music.viewmodels.OnlineSearchSuggestionViewModel +import kotlinx.coroutines.flow.drop + +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@Composable +fun OnlineSearchScreen( + query: String, + onQueryChange: (TextFieldValue) -> Unit, + navController: NavController, + onSearch: (String) -> Unit, + onDismiss: () -> Unit, + viewModel: OnlineSearchSuggestionViewModel = hiltViewModel(), +) { + val database = LocalDatabase.current + val keyboardController = LocalSoftwareKeyboardController.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val viewState by viewModel.viewState.collectAsState() + + val lazyListState = rememberLazyListState() + + LaunchedEffect(Unit) { + snapshotFlow { lazyListState.firstVisibleItemScrollOffset } + .drop(1) + .collect { + keyboardController?.hide() + } + } + + LaunchedEffect(query) { + viewModel.query.value = query + } + + LazyColumn( + state = lazyListState + ) { + items( + items = viewState.history, + key = { it.query } + ) { history -> + SuggestionItem( + query = history.query, + online = false, + onClick = { + onSearch(history.query) + onDismiss() + }, + onDelete = { + database.query { + delete(history) + } + }, + onFillTextField = { + onQueryChange( + TextFieldValue( + text = history.query, + selection = TextRange(history.query.length) + ) + ) + }, + modifier = Modifier.animateItemPlacement() + ) + } + + items( + items = viewState.suggestions, + key = { it } + ) { query -> + SuggestionItem( + query = query, + online = true, + onClick = { + onSearch(query) + onDismiss() + }, + onFillTextField = { + onQueryChange( + TextFieldValue( + text = query, + selection = TextRange(query.length) + ) + ) + }, + modifier = Modifier.animateItemPlacement() + ) + } + + if (viewState.items.isNotEmpty() && viewState.history.size + viewState.suggestions.size > 0) { + item { + Divider() + } + } + + items( + items = viewState.items, + key = { it.id } + ) { item -> + YouTubeListItem( + item = item, + isActive = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + modifier = Modifier + .clickable { + when (item) { + is SongItem -> { + if (item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + onDismiss() + } + } + + is AlbumItem -> { + navController.navigate("album/${item.id}") + onDismiss() + } + + is ArtistItem -> { + navController.navigate("artist/${item.id}") + onDismiss() + } + + is PlaylistItem -> { + navController.navigate("online_playlist/${item.id}") + onDismiss() + } + } + } + .animateItemPlacement() + ) + } + } +} + +@Composable +fun SuggestionItem( + modifier: Modifier = Modifier, + query: String, + online: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit = {}, + onFillTextField: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .height(SuggestionItemHeight) + .clickable(onClick = onClick) + .padding(end = SearchBarIconOffsetX) + ) { + Icon( + painterResource(if (online) R.drawable.search else R.drawable.history), + contentDescription = null, + modifier = Modifier + .padding(horizontal = 16.dp) + .alpha(0.5f) + ) + + Text( + text = query, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + if (!online) { + IconButton( + onClick = onDelete, + modifier = Modifier.alpha(0.5f) + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } + + IconButton( + onClick = onFillTextField, + modifier = Modifier.alpha(0.5f) + ) { + Icon( + painter = painterResource(R.drawable.arrow_top_left), + contentDescription = null + ) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AboutScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AboutScreen.kt new file mode 100644 index 000000000..97168db71 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AboutScreen.kt @@ -0,0 +1,144 @@ +package com.zionhuang.music.ui.screens.settings + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.zionhuang.music.BuildConfig +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(4.dp)) + + Image( + painter = painterResource(R.drawable.launcher_monochrome), + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation)) + .clickable { } + ) + + Text( + text = "InnerTune", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp) + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = BuildConfig.VERSION_NAME, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary + ) + if (BuildConfig.DEBUG) { + Spacer(Modifier.width(4.dp)) + + Text( + text = "DEBUG", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.secondary, + shape = CircleShape + ) + .padding( + horizontal = 4.dp, + vertical = 2.dp + ) + ) + } + } + + Spacer(Modifier.height(4.dp)) + + Text( + text = "by Zion Huang", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary + ) + + Spacer(Modifier.height(8.dp)) + + Row { + IconButton( + onClick = { uriHandler.openUri("https://github.com/z-huang/InnerTune") } + ) { + Icon( + painter = painterResource(R.drawable.github), + contentDescription = null + ) + } + + IconButton( + onClick = { uriHandler.openUri("https://liberapay.com/zionhuang") } + ) { + Icon( + painter = painterResource(R.drawable.liberapay), + contentDescription = null + ) + } + + IconButton( + onClick = { uriHandler.openUri("https://www.buymeacoffee.com/zionhuang") } + ) { + Icon( + painter = painterResource(R.drawable.buymeacoffee), + contentDescription = null + ) + } + } + + } + + TopAppBar( + title = { Text(stringResource(R.string.about)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt new file mode 100644 index 000000000..9a010bb04 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt @@ -0,0 +1,121 @@ +package com.zionhuang.music.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.R +import com.zionhuang.music.constants.DarkModeKey +import com.zionhuang.music.constants.DefaultOpenTabKey +import com.zionhuang.music.constants.DynamicThemeKey +import com.zionhuang.music.constants.LyricsTextPositionKey +import com.zionhuang.music.constants.PureBlackKey +import com.zionhuang.music.ui.component.EnumListPreference +import com.zionhuang.music.ui.component.SwitchPreference +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppearanceSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val (dynamicTheme, onDynamicThemeChange) = rememberPreference(DynamicThemeKey, defaultValue = true) + val (darkMode, onDarkModeChange) = rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val (pureBlack, onPureBlackChange) = rememberPreference(PureBlackKey, defaultValue = false) + val (defaultOpenTab, onDefaultOpenTabChange) = rememberEnumPreference(DefaultOpenTabKey, defaultValue = NavigationTab.HOME) + val (lyricsPosition, onLyricsPositionChange) = rememberEnumPreference(LyricsTextPositionKey, defaultValue = LyricsPosition.CENTER) + + Column( + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .verticalScroll(rememberScrollState()) + ) { + SwitchPreference( + title = stringResource(R.string.enable_dynamic_theme), + icon = R.drawable.palette, + checked = dynamicTheme, + onCheckedChange = onDynamicThemeChange + ) + EnumListPreference( + title = stringResource(R.string.dark_theme), + icon = R.drawable.dark_mode, + selectedValue = darkMode, + onValueSelected = onDarkModeChange, + valueText = { + when (it) { + DarkMode.ON -> stringResource(R.string.dark_theme_on) + DarkMode.OFF -> stringResource(R.string.dark_theme_off) + DarkMode.AUTO -> stringResource(R.string.dark_theme_follow_system) + } + } + ) + SwitchPreference( + title = stringResource(R.string.pure_black), + icon = R.drawable.contrast, + checked = pureBlack, + onCheckedChange = onPureBlackChange + ) + EnumListPreference( + title = stringResource(R.string.default_open_tab), + icon = R.drawable.tab, + selectedValue = defaultOpenTab, + onValueSelected = onDefaultOpenTabChange, + valueText = { + when (it) { + NavigationTab.HOME -> stringResource(R.string.home) + NavigationTab.SONG -> stringResource(R.string.songs) + NavigationTab.ARTIST -> stringResource(R.string.artists) + NavigationTab.ALBUM -> stringResource(R.string.albums) + NavigationTab.PLAYLIST -> stringResource(R.string.playlists) + } + } + ) + EnumListPreference( + title = stringResource(R.string.lyrics_text_position), + icon = R.drawable.lyrics, + selectedValue = lyricsPosition, + onValueSelected = onLyricsPositionChange, + valueText = { + when (it) { + LyricsPosition.LEFT -> stringResource(R.string.left) + LyricsPosition.CENTER -> stringResource(R.string.center) + LyricsPosition.RIGHT -> stringResource(R.string.right) + } + } + ) + } + + TopAppBar( + title = { Text(stringResource(R.string.appearance)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} + +enum class DarkMode { + ON, OFF, AUTO +} + +enum class NavigationTab { + HOME, SONG, ARTIST, ALBUM, PLAYLIST +} + +enum class LyricsPosition { + LEFT, CENTER, RIGHT +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt new file mode 100644 index 000000000..c3adc8ac7 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt @@ -0,0 +1,90 @@ +package com.zionhuang.music.ui.screens.settings + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.R +import com.zionhuang.music.ui.component.PreferenceEntry +import com.zionhuang.music.viewmodels.BackupRestoreViewModel +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BackupAndRestore( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: BackupRestoreViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> + if (uri != null) { + viewModel.backup(context, uri) + } + } + val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + viewModel.restore(context, uri) + } + } + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + viewModel.import(context, uri) + } + } + + Column( + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .verticalScroll(rememberScrollState()) + ) { + PreferenceEntry( + title = stringResource(R.string.backup), + icon = R.drawable.backup, + onClick = { + val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") + backupLauncher.launch("${context.getString(R.string.app_name)}_${LocalDateTime.now().format(formatter)}.backup") + } + ) + PreferenceEntry( + title = stringResource(R.string.restore), + icon = R.drawable.restore, + onClick = { + restoreLauncher.launch(arrayOf("application/octet-stream")) + } + ) + PreferenceEntry( + title = stringResource(R.string.import_playlist), + description = stringResource(R.string.choose_csv_file_from_google_takeout), + icon = R.drawable.input, + onClick = { + importLauncher.launch(arrayOf("*/*")) + } + ) + } + + TopAppBar( + title = { Text(stringResource(R.string.backup_restore)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt new file mode 100644 index 000000000..ed49b9e1a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt @@ -0,0 +1,105 @@ +package com.zionhuang.music.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.R +import com.zionhuang.music.constants.* +import com.zionhuang.music.ui.component.EditTextPreference +import com.zionhuang.music.ui.component.ListPreference +import com.zionhuang.music.ui.component.PreferenceGroupTitle +import com.zionhuang.music.ui.component.SwitchPreference +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference +import java.net.Proxy + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContentSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val (contentLanguage, onContentLanguageChange) = rememberPreference(key = ContentLanguageKey, defaultValue = "system") + val (contentCountry, onContentCountryChange) = rememberPreference(key = ContentCountryKey, defaultValue = "system") + val (proxyEnabled, onProxyEnabledChange) = rememberPreference(key = ProxyEnabledKey, defaultValue = false) + val (proxyType, onProxyTypeChange) = rememberEnumPreference(key = ProxyTypeKey, defaultValue = Proxy.Type.HTTP) + val (proxyUrl, onProxyUrlChange) = rememberPreference(key = ProxyUrlKey, defaultValue = "host:port") + + + Column( + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .verticalScroll(rememberScrollState()) + ) { + ListPreference( + title = stringResource(R.string.content_language), + icon = R.drawable.language, + selectedValue = contentLanguage, + values = listOf(SYSTEM_DEFAULT) + LanguageCodeToName.keys.toList(), + valueText = { + LanguageCodeToName.getOrElse(it) { + stringResource(R.string.system_default) + } + }, + onValueSelected = onContentLanguageChange + ) + ListPreference( + title = stringResource(R.string.content_country), + icon = R.drawable.location_on, + selectedValue = contentCountry, + values = listOf(SYSTEM_DEFAULT) + CountryCodeToName.keys.toList(), + valueText = { + CountryCodeToName.getOrElse(it) { + stringResource(R.string.system_default) + } + }, + onValueSelected = onContentCountryChange + ) + + PreferenceGroupTitle( + title = "PROXY" + ) + + SwitchPreference( + title = stringResource(R.string.enable_proxy), + checked = proxyEnabled, + onCheckedChange = onProxyEnabledChange + ) + + if (proxyEnabled) { + ListPreference( + title = stringResource(R.string.proxy_type), + selectedValue = proxyType, + values = listOf(Proxy.Type.HTTP, Proxy.Type.SOCKS), + valueText = { it.name }, + onValueSelected = onProxyTypeChange + ) + EditTextPreference( + title = stringResource(R.string.proxy_url), + value = proxyUrl, + onValueChange = onProxyUrlChange + ) + } + } + + TopAppBar( + title = { Text(stringResource(R.string.content)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt new file mode 100644 index 000000000..5679864a8 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt @@ -0,0 +1,86 @@ +package com.zionhuang.music.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.R +import com.zionhuang.music.constants.AudioNormalizationKey +import com.zionhuang.music.constants.AudioQuality +import com.zionhuang.music.constants.AudioQualityKey +import com.zionhuang.music.constants.PersistentQueueKey +import com.zionhuang.music.constants.SkipSilenceKey +import com.zionhuang.music.ui.component.EnumListPreference +import com.zionhuang.music.ui.component.SwitchPreference +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlayerSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val (audioQuality, onAudioQualityChange) = rememberEnumPreference(key = AudioQualityKey, defaultValue = AudioQuality.AUTO) + val (persistentQueue, onPersistentQueueChange) = rememberPreference(key = PersistentQueueKey, defaultValue = true) + val (skipSilence, onSkipSilenceChange) = rememberPreference(key = SkipSilenceKey, defaultValue = false) + val (audioNormalization, onAudioNormalizationChange) = rememberPreference(key = AudioNormalizationKey, defaultValue = true) + + Column( + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .verticalScroll(rememberScrollState()) + ) { + EnumListPreference( + title = stringResource(R.string.audio_quality), + icon = R.drawable.graphic_eq, + selectedValue = audioQuality, + onValueSelected = onAudioQualityChange, + valueText = { + when (it) { + AudioQuality.AUTO -> stringResource(R.string.audio_quality_auto) + AudioQuality.HIGH -> stringResource(R.string.audio_quality_high) + AudioQuality.LOW -> stringResource(R.string.audio_quality_low) + } + } + ) + SwitchPreference( + title = stringResource(R.string.persistent_queue), + icon = R.drawable.queue_music, + checked = persistentQueue, + onCheckedChange = onPersistentQueueChange + ) + SwitchPreference( + title = stringResource(R.string.skip_silence), + icon = R.drawable.skip_next, + checked = skipSilence, + onCheckedChange = onSkipSilenceChange + ) + SwitchPreference( + title = stringResource(R.string.audio_normalization), + icon = R.drawable.volume_up, + checked = audioNormalization, + onCheckedChange = onAudioNormalizationChange + ) + } + + TopAppBar( + title = { Text(stringResource(R.string.player_and_audio)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt new file mode 100644 index 000000000..d8e1fd7db --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt @@ -0,0 +1,154 @@ +package com.zionhuang.music.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.R +import com.zionhuang.music.constants.EnableKugouKey +import com.zionhuang.music.constants.PauseListenHistoryKey +import com.zionhuang.music.constants.PauseSearchHistoryKey +import com.zionhuang.music.ui.component.DefaultDialog +import com.zionhuang.music.ui.component.PreferenceEntry +import com.zionhuang.music.ui.component.SwitchPreference +import com.zionhuang.music.utils.rememberPreference + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivacySettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val database = LocalDatabase.current + val (pauseListenHistory, onPauseListenHistoryChange) = rememberPreference(key = PauseListenHistoryKey, defaultValue = false) + val (pauseSearchHistory, onPauseSearchHistoryChange) = rememberPreference(key = PauseSearchHistoryKey, defaultValue = false) + val (enableKugou, onEnableKugouChange) = rememberPreference(key = EnableKugouKey, defaultValue = true) + + var showClearListenHistoryDialog by remember { + mutableStateOf(false) + } + + if (showClearListenHistoryDialog) { + DefaultDialog( + onDismiss = { showClearListenHistoryDialog = false }, + content = { + Text( + text = stringResource(R.string.clear_listen_history_confirm), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { showClearListenHistoryDialog = false } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showClearListenHistoryDialog = false + database.query { + clearListenHistory() + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + var showClearSearchHistoryDialog by remember { + mutableStateOf(false) + } + + if (showClearSearchHistoryDialog) { + DefaultDialog( + onDismiss = { showClearSearchHistoryDialog = false }, + content = { + Text( + text = stringResource(R.string.clear_search_history_confirm), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { showClearSearchHistoryDialog = false } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showClearSearchHistoryDialog = false + database.query { + clearSearchHistory() + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + Column( + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .verticalScroll(rememberScrollState()) + ) { + SwitchPreference( + title = stringResource(R.string.pause_listen_history), + icon = R.drawable.history, + checked = pauseListenHistory, + onCheckedChange = onPauseListenHistoryChange + ) + PreferenceEntry( + title = stringResource(R.string.clear_listen_history), + icon = R.drawable.clear_all, + onClick = { showClearListenHistoryDialog = true } + ) + SwitchPreference( + title = stringResource(R.string.pause_search_history), + icon = R.drawable.manage_search, + checked = pauseSearchHistory, + onCheckedChange = onPauseSearchHistoryChange + ) + PreferenceEntry( + title = stringResource(R.string.clear_search_history), + icon = R.drawable.clear_all, + onClick = { showClearSearchHistoryDialog = true } + ) + SwitchPreference( + title = stringResource(R.string.enable_kugou), + icon = R.drawable.lyrics, + checked = enableKugou, + onCheckedChange = onEnableKugouChange + ) + } + + TopAppBar( + title = { Text(stringResource(R.string.privacy)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 000000000..4bb4c24bb --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,77 @@ +package com.zionhuang.music.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.R +import com.zionhuang.music.ui.component.PreferenceEntry + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + Column( + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .verticalScroll(rememberScrollState()) + ) { + PreferenceEntry( + title = stringResource(R.string.appearance), + icon = R.drawable.palette, + onClick = { navController.navigate("settings/appearance") } + ) + PreferenceEntry( + title = stringResource(R.string.content), + icon = R.drawable.language, + onClick = { navController.navigate("settings/content") } + ) + PreferenceEntry( + title = stringResource(R.string.player_and_audio), + icon = R.drawable.play, + onClick = { navController.navigate("settings/player") } + ) + PreferenceEntry( + title = stringResource(R.string.storage), + icon = R.drawable.storage, + onClick = { navController.navigate("settings/storage") } + ) + PreferenceEntry( + title = stringResource(R.string.privacy), + icon = R.drawable.security, + onClick = { navController.navigate("settings/privacy") } + ) + PreferenceEntry( + title = stringResource(R.string.backup_restore), + icon = R.drawable.restore, + onClick = { navController.navigate("settings/backup_restore") } + ) + PreferenceEntry( + title = stringResource(R.string.about), + icon = R.drawable.info, + onClick = { navController.navigate("settings/about") } + ) + } + + TopAppBar( + title = { Text(stringResource(R.string.settings)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/StorageSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/StorageSettings.kt new file mode 100644 index 000000000..5ea11de3a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/StorageSettings.kt @@ -0,0 +1,211 @@ +package com.zionhuang.music.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import coil.annotation.ExperimentalCoilApi +import coil.imageLoader +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.MaxImageCacheSizeKey +import com.zionhuang.music.constants.MaxSongCacheSizeKey +import com.zionhuang.music.ui.component.ListPreference +import com.zionhuang.music.ui.component.PreferenceEntry +import com.zionhuang.music.ui.component.PreferenceGroupTitle +import com.zionhuang.music.ui.utils.formatFileSize +import com.zionhuang.music.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoilApi::class, ExperimentalMaterial3Api::class) +@Composable +fun StorageSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val context = LocalContext.current + val imageDiskCache = context.imageLoader.diskCache ?: return + val playerCache = LocalPlayerConnection.current?.service?.playerCache ?: return + val downloadCache = LocalPlayerConnection.current?.service?.downloadCache ?: return + + val coroutineScope = rememberCoroutineScope() + + var imageCacheSize by remember { + mutableStateOf(imageDiskCache.size) + } + var playerCacheSize by remember { + mutableStateOf(playerCache.cacheSpace) + } + var downloadCacheSize by remember { + mutableStateOf(downloadCache.cacheSpace) + } + + LaunchedEffect(imageDiskCache) { + while (isActive) { + delay(500) + imageCacheSize = imageDiskCache.size + } + } + LaunchedEffect(playerCache) { + while (isActive) { + delay(500) + playerCacheSize = playerCache.cacheSpace + } + } + LaunchedEffect(downloadCache) { + while (isActive) { + delay(500) + downloadCacheSize = downloadCache.cacheSpace + } + } + + val (maxImageCacheSize, onMaxImageCacheSizeChange) = rememberPreference(key = MaxImageCacheSizeKey, defaultValue = 512) + val (maxSongCacheSize, onMaxSongCacheSizeChange) = rememberPreference(key = MaxSongCacheSizeKey, defaultValue = 1024) + + Column( + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .verticalScroll(rememberScrollState()) + ) { + PreferenceGroupTitle( + title = stringResource(R.string.downloaded_songs) + ) + + Text( + text = stringResource(R.string.size_used, formatFileSize(downloadCacheSize)), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + ) + + PreferenceEntry( + title = stringResource(R.string.clear_all_downloads), + onClick = { + coroutineScope.launch(Dispatchers.IO) { + downloadCache.keys.forEach { key -> + downloadCache.removeResource(key) + } + } + }, + ) + + PreferenceGroupTitle( + title = stringResource(R.string.song_cache) + ) + + if (maxSongCacheSize == -1) { + Text( + text = stringResource(R.string.size_used, formatFileSize(playerCacheSize)), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + ) + } else { + LinearProgressIndicator( + progress = (playerCacheSize.toFloat() / (maxSongCacheSize * 1024 * 1024L)).coerceIn(0f, 1f), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + ) + + Text( + text = stringResource(R.string.size_used, "${formatFileSize(playerCacheSize)} / ${formatFileSize(maxSongCacheSize * 1024 * 1024L)}"), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + ) + } + + ListPreference( + title = stringResource(R.string.max_cache_size), + selectedValue = maxSongCacheSize, + values = listOf(128, 256, 512, 1024, 2048, 4096, 8192, -1), + valueText = { + if (it == -1) stringResource(R.string.unlimited) else formatFileSize(it * 1024 * 1024L) + }, + onValueSelected = onMaxSongCacheSizeChange + ) + + PreferenceEntry( + title = stringResource(R.string.clear_song_cache), + onClick = { + coroutineScope.launch(Dispatchers.IO) { + playerCache.keys.forEach { key -> + playerCache.removeResource(key) + } + } + }, + ) + + PreferenceGroupTitle( + title = stringResource(R.string.image_cache) + ) + + LinearProgressIndicator( + progress = (imageCacheSize.toFloat() / imageDiskCache.maxSize).coerceIn(0f, 1f), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + ) + + Text( + text = stringResource(R.string.size_used, "${formatFileSize(imageCacheSize)} / ${formatFileSize(imageDiskCache.maxSize)}"), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + ) + + ListPreference( + title = stringResource(R.string.max_cache_size), + selectedValue = maxImageCacheSize, + values = listOf(128, 256, 512, 1024, 2048, 4096, 8192), + valueText = { formatFileSize(it * 1024 * 1024L) }, + onValueSelected = onMaxImageCacheSizeChange + ) + + PreferenceEntry( + title = stringResource(R.string.clear_image_cache), + onClick = { + coroutineScope.launch(Dispatchers.IO) { + imageDiskCache.clear() + } + }, + ) + } + + TopAppBar( + title = { Text(stringResource(R.string.storage)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/theme/Theme.kt b/app/src/main/java/com/zionhuang/music/ui/theme/Theme.kt new file mode 100644 index 000000000..71b94d7d0 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/theme/Theme.kt @@ -0,0 +1,99 @@ +package com.zionhuang.music.ui.theme + +import android.graphics.Bitmap +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.palette.graphics.Palette +import com.google.material.color.scheme.Scheme +import com.google.material.color.score.Score + +val DefaultThemeColor = Color(0xFF4285F4) + +@Composable +fun InnerTuneTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + pureBlack: Boolean = false, + themeColor: Color = DefaultThemeColor, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val colorScheme = remember(darkTheme, pureBlack, themeColor) { + if (themeColor == DefaultThemeColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (darkTheme) dynamicDarkColorScheme(context).pureBlack(pureBlack) + else dynamicLightColorScheme(context) + } else { + if (darkTheme) Scheme.dark(themeColor.toArgb()).toColorScheme().pureBlack(pureBlack) + else Scheme.light(themeColor.toArgb()).toColorScheme() + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = MaterialTheme.typography, + content = content + ) +} + +fun Bitmap.extractThemeColor(): Color { + val colorsToPopulation = Palette.from(this) + .maximumColorCount(8) + .generate() + .swatches + .associate { it.rgb to it.population } + val rankedColors = Score.score(colorsToPopulation) + return Color(rankedColors.first()) +} + +fun Scheme.toColorScheme() = ColorScheme( + primary = Color(primary), + onPrimary = Color(onPrimary), + primaryContainer = Color(primaryContainer), + onPrimaryContainer = Color(onPrimaryContainer), + inversePrimary = Color(inversePrimary), + secondary = Color(secondary), + onSecondary = Color(onSecondary), + secondaryContainer = Color(secondaryContainer), + onSecondaryContainer = Color(onSecondaryContainer), + tertiary = Color(tertiary), + onTertiary = Color(onTertiary), + tertiaryContainer = Color(tertiaryContainer), + onTertiaryContainer = Color(onTertiaryContainer), + background = Color(background), + onBackground = Color(onBackground), + surface = Color(surface), + onSurface = Color(onSurface), + surfaceVariant = Color(surfaceVariant), + onSurfaceVariant = Color(onSurfaceVariant), + surfaceTint = Color(primary), + inverseSurface = Color(inverseSurface), + inverseOnSurface = Color(inverseOnSurface), + error = Color(error), + onError = Color(onError), + errorContainer = Color(errorContainer), + onErrorContainer = Color(onErrorContainer), + outline = Color(outline), + outlineVariant = Color(outlineVariant), + scrim = Color(scrim), +) + +fun ColorScheme.pureBlack(apply: Boolean) = + if (apply) copy( + surface = Color.Black, + background = Color.Black + ) else this + +val ColorSaver = object : Saver { + override fun restore(value: Int): Color = Color(value) + override fun SaverScope.save(value: Color): Int = value.toArgb() +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/AppBar.kt b/app/src/main/java/com/zionhuang/music/ui/utils/AppBar.kt new file mode 100644 index 000000000..c42145784 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/AppBar.kt @@ -0,0 +1,63 @@ +package com.zionhuang.music.ui.utils + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import com.zionhuang.music.constants.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun appBarScrollBehavior( + state: TopAppBarState = rememberTopAppBarState(), + canScroll: () -> Boolean = { true }, + snapAnimationSpec: AnimationSpec? = spring(stiffness = Spring.StiffnessMediumLow), + flingAnimationSpec: DecayAnimationSpec? = rememberSplineBasedDecay(), +): TopAppBarScrollBehavior = + AppBarScrollBehavior( + state = state, + snapAnimationSpec = snapAnimationSpec, + flingAnimationSpec = flingAnimationSpec, + canScroll = canScroll + ) + +@ExperimentalMaterial3Api +class AppBarScrollBehavior constructor( + override val state: TopAppBarState, + override val snapAnimationSpec: AnimationSpec?, + override val flingAnimationSpec: DecayAnimationSpec?, + val canScroll: () -> Boolean = { true }, +) : TopAppBarScrollBehavior { + override val isPinned: Boolean = true + override var nestedScrollConnection = object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + if (!canScroll()) return Offset.Zero + state.contentOffset += consumed.y + if (state.heightOffset == 0f || state.heightOffset == state.heightOffsetLimit) { + if (consumed.y == 0f && available.y > 0f) { + // Reset the total content offset to zero when scrolling all the way down. + // This will eliminate some float precision inaccuracies. + state.contentOffset = 0f + } + } + state.heightOffset += consumed.y + return Offset.Zero + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +suspend fun TopAppBarState.resetHeightOffset() { + if (heightOffset != 0f) { + animate( + initialValue = heightOffset, + targetValue = 0f + ) { value, _ -> + heightOffset = value + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/FadingEdge.kt b/app/src/main/java/com/zionhuang/music/ui/utils/FadingEdge.kt new file mode 100644 index 000000000..8a076c6fe --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/FadingEdge.kt @@ -0,0 +1,81 @@ +package com.zionhuang.music.ui.utils + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp + +fun Modifier.fadingEdge( + left: Dp? = null, + top: Dp? = null, + right: Dp? = null, + bottom: Dp? = null, +) = graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + if (top != null) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black + ), + startY = 0f, + endY = top.toPx() + ), + blendMode = BlendMode.DstIn + ) + } + if (bottom != null) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.Black, + Color.Transparent + ), + startY = size.height - bottom.toPx(), + endY = size.height + ), + blendMode = BlendMode.DstIn + ) + } + if (left != null) { + drawRect( + brush = Brush.horizontalGradient( + colors = listOf( + Color.Black, + Color.Transparent + ), + startX = 0f, + endX = left.toPx() + ), + blendMode = BlendMode.DstIn + ) + } + if (right != null) { + drawRect( + brush = Brush.horizontalGradient( + colors = listOf( + Color.Transparent, + Color.Black + ), + startX = size.width - right.toPx(), + endX = size.width + ), + blendMode = BlendMode.DstIn + ) + } + } + +fun Modifier.fadingEdge( + horizontal: Dp? = null, + vertical: Dp? = null, +) = fadingEdge( + left = horizontal, + right = horizontal, + top = vertical, + bottom = vertical +) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/HorizontalPager.kt b/app/src/main/java/com/zionhuang/music/ui/utils/HorizontalPager.kt new file mode 100644 index 000000000..489a0d271 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/HorizontalPager.kt @@ -0,0 +1,271 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.zionhuang.music.ui.utils + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyList +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.* +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +@ExperimentalFoundationApi +fun HorizontalPager( + items: List, + modifier: Modifier = Modifier, + state: PagerState = rememberPagerState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + pageSize: PageSize = PageSize.Fill, + beyondBoundsPageCount: Int = 0, + pageSpacing: Dp = 0.dp, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + flingBehavior: SnapFlingBehavior = PagerDefaults.flingBehavior(state = state), + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + key: ((item: T) -> Any)? = null, + pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection( + Orientation.Horizontal + ), + pageContent: @Composable (item: T) -> Unit, +) { + Pager( + modifier = modifier, + state = state, + items = items, + pageSpacing = pageSpacing, + userScrollEnabled = userScrollEnabled, + orientation = Orientation.Horizontal, + verticalAlignment = verticalAlignment, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + beyondBoundsPageCount = beyondBoundsPageCount, + pageSize = pageSize, + flingBehavior = flingBehavior, + key = key, + pageNestedScrollConnection = pageNestedScrollConnection, + pageContent = pageContent + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun Pager( + modifier: Modifier, + state: PagerState, + items: List, + pageSize: PageSize, + pageSpacing: Dp, + orientation: Orientation, + beyondBoundsPageCount: Int, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + contentPadding: PaddingValues, + flingBehavior: SnapFlingBehavior, + userScrollEnabled: Boolean, + reverseLayout: Boolean, + key: ((item: T) -> Any)?, + pageNestedScrollConnection: NestedScrollConnection, + pageContent: @Composable (item: T) -> Unit, +) { + require(beyondBoundsPageCount >= 0) { + "beyondBoundsPageCount should be greater than or equal to 0, " + + "you selected $beyondBoundsPageCount" + } + + val isVertical = orientation == Orientation.Vertical + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val calculatedContentPaddings = remember(contentPadding, orientation, layoutDirection) { + calculateContentPaddings( + contentPadding, + orientation, + layoutDirection + ) + } + + val pagerFlingBehavior = remember(flingBehavior, state) { + PagerWrapperFlingBehavior(flingBehavior, state) + } + + LaunchedEffect(density, state, pageSpacing) { + with(density) { state.pageSpacing = pageSpacing.roundToPx() } + } + + LaunchedEffect(state) { + snapshotFlow { state.isScrollInProgress } + .filter { !it } + .drop(1) // Initial scroll is false + .collect { state.updateOnScrollStopped() } + } + + val pagerSemantics = if (userScrollEnabled) { + Modifier.pagerSemantics(state, isVertical) + } else { + Modifier + } + + BoxWithConstraints(modifier = modifier.then(pagerSemantics)) { + val mainAxisSize = if (isVertical) constraints.maxHeight else constraints.maxWidth + // Calculates how pages are shown across the main axis + val pageAvailableSize = remember( + density, + mainAxisSize, + pageSpacing, + calculatedContentPaddings + ) { + with(density) { + val pageSpacingPx = pageSpacing.roundToPx() + val contentPaddingPx = calculatedContentPaddings.roundToPx() + with(pageSize) { + density.calculateMainAxisPageSize( + mainAxisSize - contentPaddingPx, + pageSpacingPx + ) + }.toDp() + } + } + + val horizontalAlignmentForSpacedArrangement = + if (!reverseLayout) Alignment.Start else Alignment.End + val verticalAlignmentForSpacedArrangement = + if (!reverseLayout) Alignment.Top else Alignment.Bottom + + val lazyListState = remember(state) { + val initialPageOffset = + with(density) { pageAvailableSize.roundToPx() } * state.initialPageOffsetFraction + LazyListState(state.initialPage, initialPageOffset.roundToInt()).also { + state.loadNewState(it) + } + } + + LazyList( + modifier = Modifier, + state = lazyListState, + contentPadding = contentPadding, + flingBehavior = pagerFlingBehavior, + horizontalAlignment = horizontalAlignment, + horizontalArrangement = Arrangement.spacedBy( + pageSpacing, + horizontalAlignmentForSpacedArrangement + ), + verticalArrangement = Arrangement.spacedBy( + pageSpacing, + verticalAlignmentForSpacedArrangement + ), + verticalAlignment = verticalAlignment, + isVertical = isVertical, + reverseLayout = reverseLayout, + userScrollEnabled = userScrollEnabled, + beyondBoundsItemCount = beyondBoundsPageCount + ) { + items(items = items, key = key) { item -> + val pageMainAxisSizeModifier = if (isVertical) { + Modifier.height(pageAvailableSize) + } else { + Modifier.width(pageAvailableSize) + } + Box( + modifier = Modifier + .then(pageMainAxisSizeModifier) + .nestedScroll(pageNestedScrollConnection), + contentAlignment = Alignment.Center + ) { + pageContent(item) + } + } + } + } +} + +private fun calculateContentPaddings( + contentPadding: PaddingValues, + orientation: Orientation, + layoutDirection: LayoutDirection, +): Dp { + + val startPadding = if (orientation == Orientation.Vertical) { + contentPadding.calculateTopPadding() + } else { + contentPadding.calculateLeftPadding(layoutDirection) + } + + val endPadding = if (orientation == Orientation.Vertical) { + contentPadding.calculateBottomPadding() + } else { + contentPadding.calculateRightPadding(layoutDirection) + } + + return startPadding + endPadding +} + +@OptIn(ExperimentalFoundationApi::class) +private class PagerWrapperFlingBehavior( + val originalFlingBehavior: SnapFlingBehavior, + val pagerState: PagerState, +) : FlingBehavior { + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + return with(originalFlingBehavior) { + performFling(initialVelocity) { remainingScrollOffset -> + pagerState.snapRemainingScrollOffset = remainingScrollOffset + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Suppress("ComposableModifierFactory") +@Composable +private fun Modifier.pagerSemantics(state: PagerState, isVertical: Boolean): Modifier { + val scope = rememberCoroutineScope() + fun performForwardPaging(): Boolean { + return if (state.canScrollForward) { + scope.launch { + state.animateToNextPage() + } + true + } else { + false + } + } + + fun performBackwardPaging(): Boolean { + return if (state.canScrollBackward) { + scope.launch { + state.animateToPreviousPage() + } + true + } else { + false + } + } + + return this.then(Modifier.semantics { + if (isVertical) { + pageUp { performBackwardPaging() } + pageDown { performForwardPaging() } + } else { + pageLeft { performBackwardPaging() } + pageRight { performForwardPaging() } + } + }) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/LazyGridSnapLayoutInfoProvider.kt b/app/src/main/java/com/zionhuang/music/ui/utils/LazyGridSnapLayoutInfoProvider.kt new file mode 100644 index 000000000..1cf10ed82 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/LazyGridSnapLayoutInfoProvider.kt @@ -0,0 +1,139 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.zionhuang.music.ui.utils + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridLayoutInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.pager.PagerState +import androidx.compose.ui.unit.Density +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastSumBy + +@ExperimentalFoundationApi +fun SnapLayoutInfoProvider( + lazyGridState: LazyGridState, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float = + { layoutSize, itemSize -> (layoutSize / 2f - itemSize / 2f) }, +): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider { + + private val layoutInfo: LazyGridLayoutInfo + get() = lazyGridState.layoutInfo + + // Single page snapping is the default + override fun Density.calculateApproachOffset(initialVelocity: Float): Float = 0f + + override fun Density.calculateSnappingOffsetBounds(): ClosedFloatingPointRange { + var lowerBoundOffset = Float.NEGATIVE_INFINITY + var upperBoundOffset = Float.POSITIVE_INFINITY + + layoutInfo.visibleItemsInfo.fastForEach { item -> + val offset = + calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout) + + // Find item that is closest to the center + if (offset <= 0 && offset > lowerBoundOffset) { + lowerBoundOffset = offset + } + + // Find item that is closest to center, but after it + if (offset >= 0 && offset < upperBoundOffset) { + upperBoundOffset = offset + } + } + + return lowerBoundOffset.rangeTo(upperBoundOffset) + } + + override fun Density.calculateSnapStepSize(): Float = with(layoutInfo) { + if (visibleItemsInfo.isNotEmpty()) { + visibleItemsInfo.fastSumBy { it.size.width } / visibleItemsInfo.size.toFloat() + } else { + 0f + } + } +} + +@ExperimentalFoundationApi +fun SnapLayoutInfoProvider( + pagerState: PagerState, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float = + { layoutSize, itemSize -> (layoutSize / 2f - itemSize / 2f) }, +): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider { + + private val layoutInfo: LazyListLayoutInfo + get() = pagerState.layoutInfo + + // Single page snapping is the default + override fun Density.calculateApproachOffset(initialVelocity: Float): Float = 0f + + override fun Density.calculateSnappingOffsetBounds(): ClosedFloatingPointRange { + var lowerBoundOffset = Float.NEGATIVE_INFINITY + var upperBoundOffset = Float.POSITIVE_INFINITY + + layoutInfo.visibleItemsInfo.fastForEach { item -> + val offset = + calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout) + + // Find item that is closest to the center + if (offset <= 0 && offset > lowerBoundOffset) { + lowerBoundOffset = offset + } + + // Find item that is closest to center, but after it + if (offset >= 0 && offset < upperBoundOffset) { + upperBoundOffset = offset + } + } + + return lowerBoundOffset.rangeTo(upperBoundOffset) + } + + override fun Density.calculateSnapStepSize(): Float = with(layoutInfo) { + if (visibleItemsInfo.isNotEmpty()) { + visibleItemsInfo.fastSumBy { it.size } / visibleItemsInfo.size.toFloat() + } else { + 0f + } + } +} + +fun Density.calculateDistanceToDesiredSnapPosition( + layoutInfo: LazyListLayoutInfo, + item: LazyListItemInfo, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float, +): Float { + val containerSize = + with(layoutInfo) { singleAxisViewportSize - beforeContentPadding - afterContentPadding } + + val desiredDistance = + positionInLayout(containerSize.toFloat(), item.size.toFloat()) + + val itemCurrentPosition = item.offset + return itemCurrentPosition - desiredDistance +} + +private val LazyListLayoutInfo.singleAxisViewportSize: Int + get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width + +fun Density.calculateDistanceToDesiredSnapPosition( + layoutInfo: LazyGridLayoutInfo, + item: LazyGridItemInfo, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float, +): Float { + val containerSize = + with(layoutInfo) { singleAxisViewportSize - beforeContentPadding - afterContentPadding } + + val desiredDistance = positionInLayout(containerSize.toFloat(), item.size.width.toFloat()) + val itemCurrentPosition = item.offset.x.toFloat() + + return itemCurrentPosition - desiredDistance +} + +private val LazyGridLayoutInfo.singleAxisViewportSize: Int + get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/LazyListStateUtils.kt b/app/src/main/java/com/zionhuang/music/ui/utils/LazyListStateUtils.kt new file mode 100644 index 000000000..cc7061c2e --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/LazyListStateUtils.kt @@ -0,0 +1,27 @@ +package com.zionhuang.music.ui.utils + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +@Composable +fun LazyListState.isScrollingUp(): Boolean { + var previousIndex by remember(this) { mutableStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember(this) { mutableStateOf(firstVisibleItemScrollOffset) } + return remember(this) { + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex > firstVisibleItemIndex + } else { + previousScrollOffset >= firstVisibleItemScrollOffset + }.also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } + } + }.value +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/NavControllerUtils.kt b/app/src/main/java/com/zionhuang/music/ui/utils/NavControllerUtils.kt new file mode 100644 index 000000000..a1973199a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/NavControllerUtils.kt @@ -0,0 +1,7 @@ +package com.zionhuang.music.ui.utils + +import androidx.navigation.NavController +import androidx.navigation.NavGraph + +val NavController.canNavigateUp: Boolean + get() = backQueue.count { entry -> entry.destination !is NavGraph } > 1 \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/ShapeUtils.kt b/app/src/main/java/com/zionhuang/music/ui/utils/ShapeUtils.kt new file mode 100644 index 000000000..a554690c7 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/ShapeUtils.kt @@ -0,0 +1,8 @@ +package com.zionhuang.music.ui.utils + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.ui.unit.dp + +fun CornerBasedShape.top(): CornerBasedShape = + copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/StringUtils.kt b/app/src/main/java/com/zionhuang/music/ui/utils/StringUtils.kt new file mode 100644 index 000000000..84f859cfa --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/StringUtils.kt @@ -0,0 +1,30 @@ +package com.zionhuang.music.ui.utils + +import kotlin.math.absoluteValue + +fun formatFileSize(sizeBytes: Long): String { + val prefix = if (sizeBytes < 0) "-" else "" + var result: Long = sizeBytes.absoluteValue + var suffix = "B" + if (result > 900) { + suffix = "KB" + result /= 1024 + } + if (result > 900) { + suffix = "MB" + result /= 1024 + } + if (result > 900) { + suffix = "GB" + result /= 1024 + } + if (result > 900) { + suffix = "TB" + result /= 1024 + } + if (result > 900) { + suffix = "PB" + result /= 1024 + } + return "$prefix$result $suffix" +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/YouTubeUtils.kt b/app/src/main/java/com/zionhuang/music/ui/utils/YouTubeUtils.kt new file mode 100644 index 000000000..110d9db99 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/YouTubeUtils.kt @@ -0,0 +1,20 @@ +package com.zionhuang.music.ui.utils + +fun String.resize( + width: Int? = null, + height: Int? = null, +): String { + if (width == null && height == null) return this + "https://lh3\\.googleusercontent\\.com/.*=w(\\d+)-h(\\d+).*".toRegex().matchEntire(this)?.groupValues?.let { group -> + val (W, H) = group.drop(1).map { it.toInt() } + var w = width + var h = height + if (w != null && h == null) h = (w / W) * H + if (w == null && h != null) w = (h / H) * W + return "${split("=w")[0]}=w$w-h$h-p-l90-rj" + } + if (this matches "https://yt3\\.ggpht\\.com/.*=s(\\d+)".toRegex()) { + return "$this-s${width ?: height}" + } + return this +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimatablesPool.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimatablesPool.kt new file mode 100644 index 000000000..553d40e6e --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimatablesPool.kt @@ -0,0 +1,38 @@ +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector +import androidx.compose.animation.core.TwoWayConverter +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class AnimatablesPool( + private val size: Int, + private val initialValue: T, + typeConverter: TwoWayConverter, +) { + private val values = MutableList(size) { + Animatable(initialValue = initialValue, typeConverter = typeConverter) + } + + private val mutex = Mutex() + + init { + require(size > 0) + } + + suspend fun acquire(): Animatable? { + return mutex.withLock { + if (values.isNotEmpty()) values.removeFirst() else null + } + } + + suspend fun release(animatable: Animatable) { + mutex.withLock { + if (values.size < size) { + animatable.snapTo(initialValue) + values.add(animatable) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimateItemPlacement.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimateItemPlacement.kt new file mode 100644 index 000000000..1123b609e --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimateItemPlacement.kt @@ -0,0 +1,10 @@ +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.ui.Modifier + +context(LazyItemScope) +@ExperimentalFoundationApi +fun Modifier.animateItemPlacement(reorderingState: ReorderingState) = + if (!reorderingState.isDragging) animateItemPlacement() else this diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/DraggedItem.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/DraggedItem.kt new file mode 100644 index 000000000..2a11c6ef8 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/DraggedItem.kt @@ -0,0 +1,32 @@ +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.zIndex + +fun Modifier.draggedItem( + reorderingState: ReorderingState, + index: Int, +): Modifier = when (reorderingState.draggingIndex) { + -1 -> this + index -> offset { + when (reorderingState.lazyListState.layoutInfo.orientation) { + Orientation.Vertical -> IntOffset(0, reorderingState.offset.value) + Orientation.Horizontal -> IntOffset(reorderingState.offset.value, 0) + } + }.zIndex(1f) + else -> offset { + val offset = when (index) { + in reorderingState.indexesToAnimate -> reorderingState.indexesToAnimate.getValue(index).value + in (reorderingState.draggingIndex + 1)..reorderingState.reachedIndex -> -reorderingState.draggingItemSize + in reorderingState.reachedIndex until reorderingState.draggingIndex -> reorderingState.draggingItemSize + else -> 0 + } + when (reorderingState.lazyListState.layoutInfo.orientation) { + Orientation.Vertical -> IntOffset(0, offset) + Orientation.Horizontal -> IntOffset(offset, 0) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/Reorder.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/Reorder.kt new file mode 100644 index 000000000..1d0411912 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/Reorder.kt @@ -0,0 +1,41 @@ +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput + +private fun Modifier.reorder( + reorderingState: ReorderingState, + index: Int, + detectDragGestures: DetectDragGestures, +): Modifier = pointerInput(reorderingState) { + with(detectDragGestures) { + detectDragGestures( + onDragStart = { reorderingState.onDragStart(index) }, + onDrag = reorderingState::onDrag, + onDragEnd = reorderingState::onDragEnd, + onDragCancel = reorderingState::onDragEnd, + ) + } +} + +fun Modifier.reorder( + reorderingState: ReorderingState, + index: Int, +): Modifier = reorder( + reorderingState = reorderingState, + index = index, + detectDragGestures = PointerInputScope::detectDragGestures, +) + +private fun interface DetectDragGestures { + suspend fun PointerInputScope.detectDragGestures( + onDragStart: (Offset) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyColumn.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyColumn.kt new file mode 100644 index 000000000..73b2e1b1d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyColumn.kt @@ -0,0 +1,40 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ReorderingLazyColumn( + reorderingState: ReorderingState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyListScope.() -> Unit, +) { + ReorderingLazyList( + modifier = modifier, + reorderingState = reorderingState, + contentPadding = contentPadding, + flingBehavior = flingBehavior, + horizontalAlignment = horizontalAlignment, + verticalArrangement = verticalArrangement, + isVertical = true, + reverseLayout = reverseLayout, + userScrollEnabled = userScrollEnabled, + content = content + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt new file mode 100644 index 000000000..7b0facb92 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt @@ -0,0 +1,274 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.checkScrollableContainerConstraints +import androidx.compose.foundation.clipScrollableContainer +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.lazy.layout.LazyLayout +import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope +import androidx.compose.foundation.lazy.layout.lazyLayoutSemantics +import androidx.compose.foundation.overscroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.* + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun ReorderingLazyList( + modifier: Modifier, + reorderingState: ReorderingState, + contentPadding: PaddingValues, + reverseLayout: Boolean, + isVertical: Boolean, + flingBehavior: FlingBehavior, + userScrollEnabled: Boolean, + beyondBoundsItemCount: Int = 0, + horizontalAlignment: Alignment.Horizontal? = null, + verticalArrangement: Arrangement.Vertical? = null, + verticalAlignment: Alignment.Vertical? = null, + horizontalArrangement: Arrangement.Horizontal? = null, + content: LazyListScope.() -> Unit, +) { + val overscrollEffect = ScrollableDefaults.overscrollEffect() + val itemProvider = rememberLazyListItemProvider(reorderingState.lazyListState, content) + val semanticState = + rememberLazyListSemanticState(reorderingState.lazyListState, itemProvider, reverseLayout, isVertical) + val beyondBoundsInfo = reorderingState.lazyListBeyondBoundsInfo + val scope = rememberCoroutineScope() + val placementAnimator = remember(reorderingState.lazyListState, isVertical) { + LazyListItemPlacementAnimator(scope, isVertical) + } + reorderingState.lazyListState.placementAnimator = placementAnimator + + val measurePolicy = rememberLazyListMeasurePolicy( + itemProvider, + reorderingState.lazyListState, + beyondBoundsInfo, + contentPadding, + reverseLayout, + isVertical, + beyondBoundsItemCount, + horizontalAlignment, + verticalAlignment, + horizontalArrangement, + verticalArrangement, + placementAnimator, + ) + + val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal + LazyLayout( + modifier = modifier + .then(reorderingState.lazyListState.remeasurementModifier) + .then(reorderingState.lazyListState.awaitLayoutModifier) + .lazyLayoutSemantics( + itemProvider = itemProvider, + state = semanticState, + orientation = orientation, + userScrollEnabled = userScrollEnabled + ) + .clipScrollableContainer(orientation) + .lazyListBeyondBoundsModifier(reorderingState.lazyListState, beyondBoundsInfo, reverseLayout, orientation) + .overscroll(overscrollEffect) + .scrollable( + orientation = orientation, + reverseDirection = ScrollableDefaults.reverseDirection( + LocalLayoutDirection.current, + orientation, + reverseLayout + ), + interactionSource = reorderingState.lazyListState.internalInteractionSource, + flingBehavior = flingBehavior, + state = reorderingState.lazyListState, + overscrollEffect = overscrollEffect, + enabled = userScrollEnabled + ), + prefetchState = reorderingState.lazyListState.prefetchState, + measurePolicy = measurePolicy, + itemProvider = itemProvider + ) +} + +@ExperimentalFoundationApi +@Composable +private fun rememberLazyListMeasurePolicy( + itemProvider: LazyListItemProvider, + state: LazyListState, + beyondBoundsInfo: LazyListBeyondBoundsInfo, + contentPadding: PaddingValues, + reverseLayout: Boolean, + isVertical: Boolean, + beyondBoundsItemCount: Int, + horizontalAlignment: Alignment.Horizontal? = null, + verticalAlignment: Alignment.Vertical? = null, + horizontalArrangement: Arrangement.Horizontal? = null, + verticalArrangement: Arrangement.Vertical? = null, + placementAnimator: LazyListItemPlacementAnimator, +) = remember MeasureResult>( + state, + beyondBoundsInfo, + contentPadding, + reverseLayout, + isVertical, + horizontalAlignment, + verticalAlignment, + horizontalArrangement, + verticalArrangement, + placementAnimator +) { + { containerConstraints -> + checkScrollableContainerConstraints( + containerConstraints, + if (isVertical) Orientation.Vertical else Orientation.Horizontal + ) + + // resolve content paddings + val startPadding = + if (isVertical) { + contentPadding.calculateLeftPadding(layoutDirection).roundToPx() + } else { + // in horizontal configuration, padding is reversed by placeRelative + contentPadding.calculateStartPadding(layoutDirection).roundToPx() + } + + val endPadding = + if (isVertical) { + contentPadding.calculateRightPadding(layoutDirection).roundToPx() + } else { + // in horizontal configuration, padding is reversed by placeRelative + contentPadding.calculateEndPadding(layoutDirection).roundToPx() + } + val topPadding = contentPadding.calculateTopPadding().roundToPx() + val bottomPadding = contentPadding.calculateBottomPadding().roundToPx() + val totalVerticalPadding = topPadding + bottomPadding + val totalHorizontalPadding = startPadding + endPadding + val totalMainAxisPadding = if (isVertical) totalVerticalPadding else totalHorizontalPadding + val beforeContentPadding = when { + isVertical && !reverseLayout -> topPadding + isVertical && reverseLayout -> bottomPadding + !isVertical && !reverseLayout -> startPadding + else -> endPadding // !isVertical && reverseLayout + } + val afterContentPadding = totalMainAxisPadding - beforeContentPadding + val contentConstraints = + containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding) + + // Update the state's cached Density + state.density = this + + // this will update the scope used by the item composables + itemProvider.itemScope.setMaxSize( + width = contentConstraints.maxWidth, + height = contentConstraints.maxHeight + ) + + val spaceBetweenItemsDp = if (isVertical) { + requireNotNull(verticalArrangement).spacing + } else { + requireNotNull(horizontalArrangement).spacing + } + val spaceBetweenItems = spaceBetweenItemsDp.roundToPx() + + val itemsCount = itemProvider.itemCount + + // can be negative if the content padding is larger than the max size from constraints + val mainAxisAvailableSize = if (isVertical) { + containerConstraints.maxHeight - totalVerticalPadding + } else { + containerConstraints.maxWidth - totalHorizontalPadding + } + val visualItemOffset = if (!reverseLayout || mainAxisAvailableSize > 0) { + IntOffset(startPadding, topPadding) + } else { + // When layout is reversed and paddings together take >100% of the available space, + // layout size is coerced to 0 when positioning. To take that space into account, + // we offset start padding by negative space between paddings. + IntOffset( + if (isVertical) startPadding else startPadding + mainAxisAvailableSize, + if (isVertical) topPadding + mainAxisAvailableSize else topPadding + ) + } + + val measuredItemProvider = LazyMeasuredItemProvider( + contentConstraints, + isVertical, + itemProvider, + this + ) { index, key, placeables -> + // we add spaceBetweenItems as an extra spacing for all items apart from the last one so + // the lazy list measuring logic will take it into account. + val spacing = if (index.value == itemsCount - 1) 0 else spaceBetweenItems + LazyMeasuredItem( + index = index.value, + placeables = placeables, + isVertical = isVertical, + horizontalAlignment = horizontalAlignment, + verticalAlignment = verticalAlignment, + layoutDirection = layoutDirection, + reverseLayout = reverseLayout, + beforeContentPadding = beforeContentPadding, + afterContentPadding = afterContentPadding, + spacing = spacing, + visualOffset = visualItemOffset, + key = key, + placementAnimator = placementAnimator + ) + } + state.premeasureConstraints = measuredItemProvider.childConstraints + + val firstVisibleItemIndex: DataIndex + val firstVisibleScrollOffset: Int + Snapshot.withoutReadObservation { + firstVisibleItemIndex = DataIndex(state.firstVisibleItemIndex) + firstVisibleScrollOffset = state.firstVisibleItemScrollOffset + } + + measureLazyList( + itemsCount = itemsCount, + itemProvider = measuredItemProvider, + mainAxisAvailableSize = mainAxisAvailableSize, + beforeContentPadding = beforeContentPadding, + afterContentPadding = afterContentPadding, + spaceBetweenItems = spaceBetweenItems, + firstVisibleItemIndex = firstVisibleItemIndex, + firstVisibleItemScrollOffset = firstVisibleScrollOffset, + scrollToBeConsumed = state.scrollToBeConsumed, + constraints = contentConstraints, + isVertical = isVertical, + headerIndexes = itemProvider.headerIndexes, + verticalArrangement = verticalArrangement, + horizontalArrangement = horizontalArrangement, + reverseLayout = reverseLayout, + density = this, + placementAnimator = placementAnimator, + beyondBoundsInfo = beyondBoundsInfo, + beyondBoundsItemCount = beyondBoundsItemCount, + pinnedItems = state.pinnedItems, + layout = { width, height, placement -> + layout( + containerConstraints.constrainWidth(width + totalHorizontalPadding), + containerConstraints.constrainHeight(height + totalVerticalPadding), + emptyMap(), + placement + ) + } + ).also { + state.applyMeasureResult(it) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingState.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingState.kt new file mode 100644 index 000000000..e480721ae --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingState.kt @@ -0,0 +1,221 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.VectorConverter +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.lazy.LazyListBeyondBoundsInfo +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputChange +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt + +/** + * From [ViMusic](https://github.com/vfsfitvnm/ViMusic) + */ +@Stable +class ReorderingState( + val lazyListState: LazyListState, + val coroutineScope: CoroutineScope, + private val lastIndex: Int, + internal val onDragStart: () -> Unit, + internal val onDragEnd: (Int, Int) -> Unit, + private val extraItemCount: Int, +) { + private lateinit var lazyListBeyondBoundsInfoInterval: LazyListBeyondBoundsInfo.Interval + internal val lazyListBeyondBoundsInfo = LazyListBeyondBoundsInfo() + internal val offset = Animatable(0, Int.VectorConverter) + + internal var draggingIndex by mutableStateOf(-1) + internal var reachedIndex by mutableStateOf(-1) + internal var draggingItemSize by mutableStateOf(0) + + lateinit var itemInfo: LazyListItemInfo + + private var previousItemSize = 0 + private var nextItemSize = 0 + + private var overscrolled = 0 + + internal var indexesToAnimate = mutableStateMapOf>() + private var animatablesPool: AnimatablesPool? = null + + val isDragging: Boolean + get() = draggingIndex != -1 + + fun onDragStart(index: Int) { + overscrolled = 0 + itemInfo = lazyListState.layoutInfo.visibleItemsInfo.find { + it.index == index + extraItemCount + } ?: return + onDragStart() + draggingIndex = index + reachedIndex = index + draggingItemSize = itemInfo.size + + nextItemSize = draggingItemSize + previousItemSize = -draggingItemSize + + offset.updateBounds( + lowerBound = -index * draggingItemSize, + upperBound = (lastIndex - index) * draggingItemSize + ) + + lazyListBeyondBoundsInfoInterval = + lazyListBeyondBoundsInfo.addInterval(index + extraItemCount, index + extraItemCount) + + val size = + lazyListState.layoutInfo.viewportEndOffset - lazyListState.layoutInfo.viewportStartOffset + + animatablesPool = AnimatablesPool(size / draggingItemSize + 2, 0, Int.VectorConverter) + } + + fun onDrag(change: PointerInputChange, dragAmount: Offset) { + if (!isDragging) return + change.consume() + + val delta = when (lazyListState.layoutInfo.orientation) { + Orientation.Vertical -> dragAmount.y + Orientation.Horizontal -> dragAmount.x + }.roundToInt() + + val targetOffset = offset.value + delta + + coroutineScope.launch { + offset.snapTo(targetOffset) + } + + if (targetOffset > nextItemSize) { + if (reachedIndex < lastIndex) { + reachedIndex += 1 + nextItemSize += draggingItemSize + previousItemSize += draggingItemSize + + val indexToAnimate = reachedIndex - if (draggingIndex < reachedIndex) 0 else 1 + + coroutineScope.launch { + val animatable = indexesToAnimate.getOrPut(indexToAnimate) { + animatablesPool?.acquire() ?: return@launch + } + + if (draggingIndex < reachedIndex) { + animatable.snapTo(0) + animatable.animateTo(-draggingItemSize) + } else { + animatable.snapTo(draggingItemSize) + animatable.animateTo(0) + } + + indexesToAnimate.remove(indexToAnimate) + animatablesPool?.release(animatable) + } + } + } else if (targetOffset < previousItemSize) { + if (reachedIndex > 0) { + reachedIndex -= 1 + previousItemSize -= draggingItemSize + nextItemSize -= draggingItemSize + + val indexToAnimate = reachedIndex + if (draggingIndex > reachedIndex) 0 else 1 + + coroutineScope.launch { + val animatable = indexesToAnimate.getOrPut(indexToAnimate) { + animatablesPool?.acquire() ?: return@launch + } + + if (draggingIndex > reachedIndex) { + animatable.snapTo(0) + animatable.animateTo(draggingItemSize) + } else { + animatable.snapTo(-draggingItemSize) + animatable.animateTo(0) + } + indexesToAnimate.remove(indexToAnimate) + animatablesPool?.release(animatable) + } + } + } else { + val offsetInViewPort = targetOffset + itemInfo.offset - overscrolled + + val topOverscroll = lazyListState.layoutInfo.viewportStartOffset + + lazyListState.layoutInfo.beforeContentPadding - offsetInViewPort + + val bottomOverscroll = lazyListState.layoutInfo.viewportEndOffset - + lazyListState.layoutInfo.afterContentPadding - offsetInViewPort - itemInfo.size + + if (topOverscroll > 0) { + overscroll(topOverscroll) + } else if (bottomOverscroll < 0) { + overscroll(bottomOverscroll) + } + } + } + + fun onDragEnd() { + if (!isDragging) return + + coroutineScope.launch { + offset.animateTo((previousItemSize + nextItemSize) / 2) + + withContext(Dispatchers.Main) { + onDragEnd(draggingIndex, reachedIndex) + } + + if (areEquals()) { + draggingIndex = -1 + reachedIndex = -1 + draggingItemSize = 0 + offset.snapTo(0) + } + + lazyListBeyondBoundsInfo.removeInterval(lazyListBeyondBoundsInfoInterval) + animatablesPool = null + } + } + + private fun overscroll(overscroll: Int) { + lazyListState.dispatchRawDelta(-overscroll.toFloat()) + coroutineScope.launch { + offset.snapTo(offset.value - overscroll) + } + overscrolled -= overscroll + } + + private fun areEquals(): Boolean { + return lazyListState.layoutInfo.visibleItemsInfo.find { + it.index + extraItemCount == draggingIndex + }?.key == lazyListState.layoutInfo.visibleItemsInfo.find { + it.index + extraItemCount == reachedIndex + }?.key + } +} + +@Composable +fun rememberReorderingState( + lazyListState: LazyListState, + key: Any, + onDragEnd: (Int, Int) -> Unit, + onDragStart: () -> Unit = {}, + extraItemCount: Int = 0, +): ReorderingState { + val coroutineScope = rememberCoroutineScope() + + return remember(key) { + ReorderingState( + lazyListState = lazyListState, + coroutineScope = coroutineScope, + lastIndex = if (key is List<*>) key.lastIndex else lazyListState.layoutInfo.totalItemsCount, + onDragStart = onDragStart, + onDragEnd = onDragEnd, + extraItemCount = extraItemCount, + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/LoadStateViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/LoadStateViewHolder.kt deleted file mode 100644 index f1723569b..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/LoadStateViewHolder.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import androidx.core.view.isVisible -import androidx.paging.LoadState -import androidx.paging.LoadState.Loading -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.databinding.LayoutLoadStateBinding -import com.zionhuang.music.extensions.context -import com.zionhuang.music.models.toErrorInfo -import com.zionhuang.music.ui.activities.ErrorActivity - -class LoadStateViewHolder( - private val binding: LayoutLoadStateBinding, - private val retry: () -> Unit, -) : RecyclerView.ViewHolder(binding.root) { - - init { - binding.btnRetry.setOnClickListener { retry() } - } - - fun bind(loadState: LoadState) { - if (loadState is LoadState.Error) { - binding.errorMsg.text = loadState.error.localizedMessage - binding.btnReport.setOnClickListener { - ErrorActivity.openActivity(binding.context, loadState.error.toErrorInfo()) - } - } - binding.errorMsg.isVisible = loadState is LoadState.Error - binding.progressBar.isVisible = loadState is Loading - binding.btnRetry.isVisible = loadState is LoadState.Error - binding.btnReport.isVisible = loadState is LoadState.Error - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/LocalItemViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/LocalItemViewHolder.kt deleted file mode 100644 index c131b41bb..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/LocalItemViewHolder.kt +++ /dev/null @@ -1,521 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import androidx.appcompat.widget.PopupMenu -import androidx.core.view.isVisible -import androidx.databinding.ViewDataBinding -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants -import com.zionhuang.music.databinding.* -import com.zionhuang.music.db.entities.* -import com.zionhuang.music.extensions.* -import com.zionhuang.music.models.sortInfo.* -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.fragments.MenuBottomSheetDialogFragment -import com.zionhuang.music.ui.listeners.* -import com.zionhuang.music.utils.joinByBullet -import com.zionhuang.music.utils.makeTimeString -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import java.time.Duration -import java.time.LocalDateTime - -sealed class LocalItemViewHolder(open val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) { - abstract val itemDetails: ItemDetailsLookup.ItemDetails? - open fun onSelectionChanged(isSelected: Boolean) {} -} - -open class SongViewHolder( - override val binding: ItemSongBinding, - private val menuListener: ISongMenuListener?, - private val draggable: Boolean = false, -) : LocalItemViewHolder(binding) { - override val itemDetails: ItemDetailsLookup.ItemDetails - get() = object : ItemDetailsLookup.ItemDetails() { - override fun getPosition(): Int = absoluteAdapterPosition - override fun getSelectionKey(): String? = binding.song?.id - } - - fun bind(song: Song, isSelected: Boolean = false) { - binding.song = song - binding.subtitle.text = listOf(song.artists.joinToString { it.name }, song.song.albumName, makeTimeString(song.song.duration.toLong() * 1000)).joinByBullet() - binding.btnMoreAction.setOnClickListener { - MenuBottomSheetDialogFragment - .newInstance(R.menu.song) - .setMenuModifier { - findItem(R.id.action_favorite).apply { - setIcon(if (song.song.liked) R.drawable.ic_favorite else R.drawable.ic_favorite_border) - setTitle(if (song.song.liked) R.string.action_remove_like else R.string.action_like) - } - findItem(R.id.action_download).isVisible = song.song.downloadState == MediaConstants.STATE_NOT_DOWNLOADED - findItem(R.id.action_remove_download).isVisible = song.song.downloadState == MediaConstants.STATE_DOWNLOADED - findItem(R.id.action_view_artist).isVisible = song.artists[0].isYouTubeArtist - findItem(R.id.action_view_album).isVisible = song.song.albumId != null - findItem(R.id.action_delete).isVisible = song.album == null - } - .setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_edit -> menuListener?.editSong(song) - R.id.action_favorite -> menuListener?.toggleLike(song) - R.id.action_radio -> menuListener?.startRadio(song) - R.id.action_play_next -> menuListener?.playNext(song) - R.id.action_add_to_queue -> menuListener?.addToQueue(song) - R.id.action_add_to_playlist -> menuListener?.addToPlaylist(song) - R.id.action_download -> menuListener?.download(song) - R.id.action_remove_download -> menuListener?.removeDownload(song) - R.id.action_view_artist -> menuListener?.viewArtist(song) - R.id.action_view_album -> menuListener?.viewAlbum(song) - R.id.action_refetch -> menuListener?.refetch(song) - R.id.action_share -> menuListener?.share(song) - R.id.action_delete -> menuListener?.delete(song) - } - } - .show(binding.context) - } - binding.selectedIndicator.isVisible = isSelected - binding.dragHandle.isVisible = draggable - binding.executePendingBindings() - } - - override fun onSelectionChanged(isSelected: Boolean) { - binding.selectedIndicator.isVisible = isSelected - } -} - -class ArtistViewHolder( - override val binding: ItemArtistBinding, - private val menuListener: IArtistMenuListener?, -) : LocalItemViewHolder(binding) { - override val itemDetails: ItemDetailsLookup.ItemDetails - get() = object : ItemDetailsLookup.ItemDetails() { - override fun getPosition(): Int = absoluteAdapterPosition - override fun getSelectionKey(): String? = binding.artist?.id - } - - @OptIn(DelicateCoroutinesApi::class) - fun bind(artist: Artist, isSelected: Boolean = false) { - binding.artist = artist - binding.btnMoreAction.setOnClickListener { - MenuBottomSheetDialogFragment - .newInstance(R.menu.artist) - .setMenuModifier { - findItem(R.id.action_edit).isVisible = false // temporary - findItem(R.id.action_share).isVisible = artist.artist.isYouTubeArtist - } - .setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_edit -> menuListener?.edit(artist) - R.id.action_play_next -> menuListener?.playNext(artist) - R.id.action_add_to_queue -> menuListener?.addToQueue(artist) - R.id.action_add_to_playlist -> menuListener?.addToPlaylist(artist) - R.id.action_refetch -> menuListener?.refetch(artist) - R.id.action_share -> menuListener?.edit(artist) - } - } - .show(binding.context) - } - binding.selectedIndicator.isVisible = isSelected - if (artist.artist.bannerUrl == null || Duration.between(artist.artist.lastUpdateTime, LocalDateTime.now()) > Duration.ofDays(10)) { - GlobalScope.launch(binding.context.exceptionHandler) { - SongRepository(binding.context).refetchArtist(artist.artist) - } - } - } - - override fun onSelectionChanged(isSelected: Boolean) { - binding.selectedIndicator.isVisible = isSelected - } -} - -class AlbumViewHolder( - override val binding: ItemAlbumBinding, - private val menuListener: IAlbumMenuListener?, -) : LocalItemViewHolder(binding) { - override val itemDetails: ItemDetailsLookup.ItemDetails - get() = object : ItemDetailsLookup.ItemDetails() { - override fun getPosition(): Int = absoluteAdapterPosition - override fun getSelectionKey(): String? = binding.album?.id - } - - @OptIn(DelicateCoroutinesApi::class) - fun bind(album: Album, isSelected: Boolean = false) { - binding.album = album - binding.subtitle.text = listOf(album.artists.joinToString { it.name }, binding.context.resources.getQuantityString(R.plurals.song_count, album.album.songCount, album.album.songCount), album.album.year?.toString()).joinByBullet() - binding.btnMoreAction.setOnClickListener { - MenuBottomSheetDialogFragment - .newInstance(R.menu.album) - .setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_play_next -> menuListener?.playNext(album) - R.id.action_add_to_queue -> menuListener?.addToQueue(album) - R.id.action_add_to_playlist -> menuListener?.addToPlaylist(album) - R.id.action_view_artist -> menuListener?.viewArtist(album) - R.id.action_refetch -> menuListener?.refetch(album) - R.id.action_share -> menuListener?.share(album) - R.id.action_delete -> menuListener?.delete(album) - } - } - .show(binding.context) - } - binding.selectedIndicator.isVisible = isSelected - if (album.album.thumbnailUrl == null || album.album.year == null) { - GlobalScope.launch(binding.context.exceptionHandler) { - SongRepository(binding.context).refetchAlbum(album.album) - } - } - } - - override fun onSelectionChanged(isSelected: Boolean) { - binding.selectedIndicator.isVisible = isSelected - } -} - -class PlaylistViewHolder( - override val binding: ItemPlaylistBinding, - private val menuListener: IPlaylistMenuListener?, - private val allowMoreAction: Boolean, -) : LocalItemViewHolder(binding) { - override val itemDetails: ItemDetailsLookup.ItemDetails - get() = object : ItemDetailsLookup.ItemDetails() { - override fun getPosition(): Int = absoluteAdapterPosition - override fun getSelectionKey(): String? = binding.playlist?.id - } - - fun bind(playlist: Playlist, isSelected: Boolean = false) { - binding.playlist = playlist - binding.subtitle.text = if (playlist.playlist.isYouTubePlaylist) { - listOf(playlist.playlist.name, playlist.playlist.year.toString()).joinByBullet() - } else { - binding.context.resources.getQuantityString(R.plurals.song_count, playlist.songCount, playlist.songCount) - } - binding.btnMoreAction.isVisible = allowMoreAction - binding.btnMoreAction.setOnClickListener { - MenuBottomSheetDialogFragment - .newInstance(R.menu.playlist) - .setMenuModifier { - findItem(R.id.action_edit).isVisible = playlist.playlist.isLocalPlaylist - findItem(R.id.action_download).isVisible = playlist.playlist.isLocalPlaylist - findItem(R.id.action_refetch).isVisible = playlist.playlist.isYouTubePlaylist - findItem(R.id.action_share).isVisible = playlist.playlist.isYouTubePlaylist - } - .setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_edit -> menuListener?.edit(playlist) - R.id.action_play -> menuListener?.play(playlist) - R.id.action_play_next -> menuListener?.playNext(playlist) - R.id.action_add_to_queue -> menuListener?.addToQueue(playlist) - R.id.action_add_to_playlist -> menuListener?.addToPlaylist(playlist) - R.id.action_download -> menuListener?.download(playlist) - R.id.action_refetch -> menuListener?.refetch(playlist) - R.id.action_share -> menuListener?.share(playlist) - R.id.action_delete -> menuListener?.delete(playlist) - } - } - .show(binding.context) - } - binding.selectedIndicator.isVisible = isSelected - } - - override fun onSelectionChanged(isSelected: Boolean) { - binding.selectedIndicator.isVisible = isSelected - } -} - -class CustomPlaylistViewHolder( - override val binding: ItemCustomPlaylistBinding, -) : LocalItemViewHolder(binding) { - override val itemDetails: ItemDetailsLookup.ItemDetails? = null - - fun bind(playlist: LikedPlaylist, menuListener: LikedPlaylistMenuListener?) { - binding.title.setText(R.string.liked_songs) - binding.subtitle.text = binding.context.resources.getQuantityString(R.plurals.song_count, playlist.songCount, playlist.songCount) - binding.thumbnail.setImageResource(R.drawable.ic_favorite) - binding.offlineIcon.isVisible = false - binding.btnMoreAction.setOnClickListener { - MenuBottomSheetDialogFragment - .newInstance(R.menu.playlist) - .setMenuModifier { - findItem(R.id.action_edit).isVisible = false - findItem(R.id.action_refetch).isVisible = false - findItem(R.id.action_share).isVisible = false - findItem(R.id.action_delete).isVisible = false - } - .setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_play -> menuListener?.play() - R.id.action_play_next -> menuListener?.playNext() - R.id.action_add_to_queue -> menuListener?.addToQueue() - R.id.action_add_to_playlist -> menuListener?.addToPlaylist() - R.id.action_download -> menuListener?.download() - } - } - .show(binding.context) - } - } - - fun bind(playlist: DownloadedPlaylist, menuListener: DownloadedPlaylistMenuListener?) { - binding.title.setText(R.string.downloaded_songs) - binding.subtitle.text = binding.context.resources.getQuantityString(R.plurals.song_count, playlist.songCount, playlist.songCount) - binding.thumbnail.setImageResource(R.drawable.ic_save_alt) - binding.offlineIcon.isVisible = true - binding.btnMoreAction.setOnClickListener { - MenuBottomSheetDialogFragment - .newInstance(R.menu.playlist) - .setMenuModifier { - findItem(R.id.action_edit).isVisible = false - findItem(R.id.action_download).isVisible = false - findItem(R.id.action_refetch).isVisible = false - findItem(R.id.action_share).isVisible = false - findItem(R.id.action_delete).isVisible = false - } - .setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_play -> menuListener?.play() - R.id.action_play_next -> menuListener?.playNext() - R.id.action_add_to_queue -> menuListener?.addToQueue() - R.id.action_add_to_playlist -> menuListener?.addToPlaylist() - } - } - .show(binding.context) - } - } - - override fun onSelectionChanged(isSelected: Boolean) { - binding.selectedIndicator.isVisible = isSelected - } -} - -class SongHeaderViewHolder( - override val binding: ItemHeaderBinding, - private val onShuffle: () -> Unit = {}, -) : LocalItemViewHolder(binding) { - override val itemDetails: ItemDetailsLookup.ItemDetails? = null - - fun bind(header: SongHeader, isPayload: Boolean = false) { - binding.sortName.setOnClickListener { view -> - PopupMenu(view.context, view).apply { - inflate(R.menu.sort_song) - setOnMenuItemClickListener { - SongSortInfoPreference.type = when (it.itemId) { - R.id.sort_by_create_date -> SongSortType.CREATE_DATE - R.id.sort_by_name -> SongSortType.NAME - R.id.sort_by_artist -> SongSortType.ARTIST - R.id.sort_by_play_time -> SongSortType.PLAY_TIME - else -> throw IllegalArgumentException("Unexpected sort type.") - } - true - } - menu.findItem(when (header.sortInfo.type) { - SongSortType.CREATE_DATE -> R.id.sort_by_create_date - SongSortType.NAME -> R.id.sort_by_name - SongSortType.ARTIST -> R.id.sort_by_artist - SongSortType.PLAY_TIME -> R.id.sort_by_play_time - })?.isChecked = true - show() - } - } - binding.sortName.setText(when (header.sortInfo.type) { - SongSortType.CREATE_DATE -> R.string.sort_by_create_date - SongSortType.NAME -> R.string.sort_by_name - SongSortType.ARTIST -> R.string.sort_by_artist - SongSortType.PLAY_TIME -> R.string.sort_by_play_time - }) - binding.sortOrder.setOnClickListener { - SongSortInfoPreference.toggleIsDescending() - } - updateSortOrderIcon(header.sortInfo.isDescending, isPayload) - binding.btnShuffle.isVisible = true - binding.btnShuffle.setOnClickListener { - onShuffle() - } - binding.countText.text = binding.context.resources.getQuantityString(R.plurals.song_count, header.songCount, header.songCount) - } - - private fun updateSortOrderIcon(sortDescending: Boolean, animate: Boolean = true) { - if (sortDescending) { - binding.sortOrder.animateToDown(animate) - } else { - binding.sortOrder.animateToUp(animate) - } - } -} - -class ArtistHeaderViewHolder( - override val binding: ItemHeaderBinding, -) : LocalItemViewHolder(binding) { - override val itemDetails: ItemDetailsLookup.ItemDetails? = null - - fun bind(header: ArtistHeader, isPayload: Boolean = false) { - binding.sortName.setOnClickListener { view -> - PopupMenu(view.context, view).apply { - inflate(R.menu.sort_artist) - setOnMenuItemClickListener { - ArtistSortInfoPreference.type = when (it.itemId) { - R.id.sort_by_create_date -> ArtistSortType.CREATE_DATE - R.id.sort_by_name -> ArtistSortType.NAME - R.id.sort_by_song_count -> ArtistSortType.SONG_COUNT - else -> throw IllegalArgumentException("Unexpected sort type.") - } - true - } - menu.findItem(when (header.sortInfo.type) { - ArtistSortType.CREATE_DATE -> R.id.sort_by_create_date - ArtistSortType.NAME -> R.id.sort_by_name - ArtistSortType.SONG_COUNT -> R.id.sort_by_song_count - })?.isChecked = true - show() - } - } - binding.sortOrder.setOnClickListener { - ArtistSortInfoPreference.toggleIsDescending() - } - binding.sortName.setText(when (header.sortInfo.type) { - ArtistSortType.CREATE_DATE -> R.string.sort_by_create_date - ArtistSortType.NAME -> R.string.sort_by_name - ArtistSortType.SONG_COUNT -> R.string.sort_by_song_count - }) - updateSortOrderIcon(header.sortInfo.isDescending, isPayload) - binding.countText.text = binding.context.resources.getQuantityString(R.plurals.artist_count, header.artistCount, header.artistCount) - } - - private fun updateSortOrderIcon(sortDescending: Boolean, animate: Boolean = true) { - if (sortDescending) { - binding.sortOrder.animateToDown(animate) - } else { - binding.sortOrder.animateToUp(animate) - } - } -} - -class AlbumHeaderViewHolder( - override val binding: ItemHeaderBinding, -) : LocalItemViewHolder(binding) { - override val itemDetails: ItemDetailsLookup.ItemDetails? = null - - fun bind(header: AlbumHeader, isPayload: Boolean = false) { - binding.sortName.setOnClickListener { view -> - PopupMenu(view.context, view).apply { - inflate(R.menu.sort_album) - setOnMenuItemClickListener { - AlbumSortInfoPreference.type = when (it.itemId) { - R.id.sort_by_create_date -> AlbumSortType.CREATE_DATE - R.id.sort_by_name -> AlbumSortType.NAME - R.id.sort_by_artist -> AlbumSortType.ARTIST - R.id.sort_by_year -> AlbumSortType.YEAR - R.id.sort_by_song_count -> AlbumSortType.SONG_COUNT - R.id.sort_by_length -> AlbumSortType.LENGTH - else -> throw IllegalArgumentException("Unexpected sort type.") - } - true - } - menu.findItem(when (header.sortInfo.type) { - AlbumSortType.CREATE_DATE -> R.id.sort_by_create_date - AlbumSortType.NAME -> R.id.sort_by_name - AlbumSortType.ARTIST -> R.id.sort_by_artist - AlbumSortType.YEAR -> R.id.sort_by_year - AlbumSortType.SONG_COUNT -> R.id.sort_by_song_count - AlbumSortType.LENGTH -> R.id.sort_by_length - })?.isChecked = true - show() - } - } - binding.sortOrder.setOnClickListener { - AlbumSortInfoPreference.toggleIsDescending() - } - binding.sortName.setText(when (header.sortInfo.type) { - AlbumSortType.CREATE_DATE -> R.string.sort_by_create_date - AlbumSortType.NAME -> R.string.sort_by_name - AlbumSortType.ARTIST -> R.string.sort_by_artist - AlbumSortType.YEAR -> R.string.sort_by_year - AlbumSortType.SONG_COUNT -> R.string.sort_by_song_count - AlbumSortType.LENGTH -> R.string.sort_by_length - }) - updateSortOrderIcon(header.sortInfo.isDescending, isPayload) - binding.countText.text = binding.context.resources.getQuantityString(R.plurals.album_count, header.albumCount, header.albumCount) - } - - private fun updateSortOrderIcon(sortDescending: Boolean, animate: Boolean = true) { - if (sortDescending) { - binding.sortOrder.animateToDown(animate) - } else { - binding.sortOrder.animateToUp(animate) - } - } -} - - -class PlaylistHeaderViewHolder( - override val binding: ItemHeaderBinding, -) : LocalItemViewHolder(binding) { - override val itemDetails: ItemDetailsLookup.ItemDetails? = null - - fun bind(header: PlaylistHeader, isPayload: Boolean = false) { - binding.sortName.setOnClickListener { view -> - PopupMenu(view.context, view).apply { - inflate(R.menu.sort_playlist) - setOnMenuItemClickListener { - PlaylistSortInfoPreference.type = when (it.itemId) { - R.id.sort_by_create_date -> PlaylistSortType.CREATE_DATE - R.id.sort_by_name -> PlaylistSortType.NAME - R.id.sort_by_song_count -> PlaylistSortType.SONG_COUNT - else -> throw IllegalArgumentException("Unexpected sort type.") - } - true - } - menu.findItem(when (header.sortInfo.type) { - PlaylistSortType.CREATE_DATE -> R.id.sort_by_create_date - PlaylistSortType.NAME -> R.id.sort_by_name - PlaylistSortType.SONG_COUNT -> R.id.sort_by_song_count - })?.isChecked = true - show() - } - } - binding.sortOrder.setOnClickListener { - PlaylistSortInfoPreference.toggleIsDescending() - } - binding.sortName.setText(when (header.sortInfo.type) { - PlaylistSortType.CREATE_DATE -> R.string.sort_by_create_date - PlaylistSortType.NAME -> R.string.sort_by_name - PlaylistSortType.SONG_COUNT -> R.string.sort_by_song_count - }) - updateSortOrderIcon(header.sortInfo.isDescending, isPayload) - binding.countText.text = binding.context.resources.getQuantityString(R.plurals.playlist_count, header.playlistCount, header.playlistCount) - } - - private fun updateSortOrderIcon(sortDescending: Boolean, animate: Boolean = true) { - if (sortDescending) { - binding.sortOrder.animateToDown(animate) - } else { - binding.sortOrder.animateToUp(animate) - } - } -} - -class PlaylistSongHeaderViewHolder( - override val binding: ItemPlaylistHeaderBinding, - private val onShuffle: () -> Unit = {}, -) : LocalItemViewHolder(binding) { - override val itemDetails: ItemDetailsLookup.ItemDetails? = null - - fun bind(header: PlaylistSongHeader) { - binding.title.text = listOf( - binding.context.resources.getQuantityString(R.plurals.song_count, header.songCount, header.songCount), - makeTimeString(header.length * 1000) - ).joinByBullet() - binding.btnShuffle.setOnClickListener { - onShuffle() - } - } -} - -class TextHeaderViewHolder( - override val binding: ItemTextHeaderBinding, -) : LocalItemViewHolder(binding) { - override val itemDetails: ItemDetailsLookup.ItemDetails? = null - - fun bind(header: TextHeader) { - binding.title.text = header.title - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/LyricsItemViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/LyricsItemViewHolder.kt deleted file mode 100644 index 41b242445..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/LyricsItemViewHolder.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import android.widget.TextView -import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.databinding.ItemLyricsBinding -import com.zionhuang.music.extensions.context -import com.zionhuang.music.utils.lyrics.LyricsHelper - -class LyricsItemViewHolder( - val binding: ItemLyricsBinding, -) : RecyclerView.ViewHolder(binding.root) { - fun bind(lyricsResult: LyricsHelper.LyricsResult) { - binding.lyrics.text = lyricsResult.lyrics - binding.provider.text = lyricsResult.providerName - binding.synced.isVisible = lyricsResult.lyrics.startsWith("[") - binding.btnView.setOnClickListener { - MaterialAlertDialogBuilder(binding.context) - .setMessage(lyricsResult.lyrics) - .setPositiveButton(android.R.string.ok, null) - .show() - .apply { - window?.decorView?.findViewById(android.R.id.message)?.setTextIsSelectable(true) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/QueueItemViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/QueueItemViewHolder.kt deleted file mode 100644 index f28b19953..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/QueueItemViewHolder.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import android.support.v4.media.MediaMetadataCompat.METADATA_KEY_DURATION -import android.support.v4.media.session.MediaSessionCompat -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.databinding.ItemQueueBinding -import com.zionhuang.music.utils.joinByBullet -import com.zionhuang.music.utils.makeTimeString - -class QueueItemViewHolder(val binding: ItemQueueBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: MediaSessionCompat.QueueItem) { - binding.item = item - binding.subtitle.text = listOf(item.description.subtitle.toString(), makeTimeString(item.description.extras?.getLong(METADATA_KEY_DURATION))).joinByBullet() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/YouTubeViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/YouTubeViewHolder.kt deleted file mode 100644 index a7eb2b8bc..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/YouTubeViewHolder.kt +++ /dev/null @@ -1,256 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import android.view.ViewGroup -import androidx.annotation.LayoutRes -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams -import androidx.databinding.ViewDataBinding -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.innertube.models.* -import com.zionhuang.innertube.models.Icon.Companion.ICON_EXPLORE -import com.zionhuang.innertube.models.Icon.Companion.ICON_MUSIC_NEW_RELEASE -import com.zionhuang.innertube.models.Icon.Companion.ICON_STICKER_EMOTICON -import com.zionhuang.innertube.models.Icon.Companion.ICON_TRENDING_UP -import com.zionhuang.innertube.models.SuggestionTextItem.SuggestionSource.LOCAL -import com.zionhuang.music.R -import com.zionhuang.music.databinding.* -import com.zionhuang.music.extensions.context -import com.zionhuang.music.extensions.show -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.adapters.YouTubeItemAdapter -import com.zionhuang.music.ui.fragments.MenuBottomSheetDialogFragment -import com.zionhuang.music.ui.viewholders.base.BindingViewHolder -import com.zionhuang.music.utils.NavigationEndpointHandler -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -sealed class YouTubeViewHolder(viewGroup: ViewGroup, @LayoutRes layoutId: Int) : BindingViewHolder(viewGroup, layoutId) - -class YouTubeHeaderViewHolder( - val viewGroup: ViewGroup, - private val navigationEndpointHandler: NavigationEndpointHandler, -) : YouTubeViewHolder(viewGroup, R.layout.item_youtube_header) { - fun bind(header: Header) { - binding.header = header - binding.root.isEnabled = header.moreNavigationEndpoint != null - if (header.moreNavigationEndpoint != null) { - binding.root.setOnClickListener { - navigationEndpointHandler.handle(header.moreNavigationEndpoint) - } - } else { - binding.root.setOnClickListener(null) - } - } -} - -class YouTubeArtistHeaderViewHolder( - val viewGroup: ViewGroup, - private val navigationEndpointHandler: NavigationEndpointHandler, -) : YouTubeViewHolder(viewGroup, R.layout.item_youtube_header_artist) { - fun bind(header: ArtistHeader) { - binding.header = header - header.bannerThumbnails?.last()?.let { thumbnail -> - binding.banner.updateLayoutParams { - dimensionRatio = "${thumbnail.width}:${thumbnail.height}" - } - } - binding.btnShuffle.setOnClickListener { - navigationEndpointHandler.handle(header.shuffleEndpoint) - } - binding.btnRadio.setOnClickListener { - navigationEndpointHandler.handle(header.radioEndpoint) - } - } -} - -class YouTubeAlbumOrPlaylistHeaderViewHolder( - val viewGroup: ViewGroup, - private val navigationEndpointHandler: NavigationEndpointHandler, - private val onPlayAlbum: (() -> Unit)? = null, - private val onShuffleAlbum: (() -> Unit)? = null, -) : YouTubeViewHolder(viewGroup, R.layout.item_youtube_header_album) { - fun bind(header: AlbumOrPlaylistHeader) { - binding.header = header - binding.btnPlay.isVisible = onPlayAlbum != null || header.menu.playEndpoint != null - binding.btnRadio.isVisible = header.menu.radioEndpoint != null && !binding.btnPlay.isVisible - binding.btnPlay.setOnClickListener { - if (onPlayAlbum != null) { - onPlayAlbum.invoke() - } else { - navigationEndpointHandler.handle(header.menu.playEndpoint) - } - } - binding.btnRadio.setOnClickListener { - navigationEndpointHandler.handle(header.menu.radioEndpoint) - } - binding.btnShuffle.setOnClickListener { - if (onShuffleAlbum != null) { - onShuffleAlbum.invoke() - } else { - navigationEndpointHandler.handle(header.menu.shuffleEndpoint) - } - } - } -} - -class YouTubeDescriptionViewHolder( - val viewGroup: ViewGroup, -) : YouTubeViewHolder(viewGroup, R.layout.item_youtube_description) { - fun bind(section: DescriptionSection) { - binding.section = section - } -} - -class YouTubeItemContainerViewHolder( - val viewGroup: ViewGroup, - private val navigationEndpointHandler: NavigationEndpointHandler, -) : YouTubeViewHolder(viewGroup, R.layout.item_recyclerview) { - fun bind(section: YTBaseItem) { - when (section) { - is CarouselSection -> { - val itemAdapter = YouTubeItemAdapter(navigationEndpointHandler, section.itemViewType, false) - binding.recyclerView.layoutManager = GridLayoutManager(binding.context, section.numItemsPerColumn, RecyclerView.HORIZONTAL, false) - binding.recyclerView.adapter = itemAdapter - itemAdapter.submitList(section.items) - } - is GridSection -> { - val itemAdapter = YouTubeItemAdapter(navigationEndpointHandler, YTBaseItem.ViewType.BLOCK, true) - binding.recyclerView.layoutManager = GridLayoutManager(binding.context, 2) // TODO spanCount for landscape or bigger screen - binding.recyclerView.adapter = itemAdapter - itemAdapter.submitList(section.items) - } - else -> {} - } - } -} - -sealed class YouTubeItemViewHolder(viewGroup: ViewGroup, @LayoutRes layoutId: Int) : YouTubeViewHolder(viewGroup, layoutId) { - abstract fun bind(item: YTItem) -} - -class YouTubeListItemViewHolder( - val viewGroup: ViewGroup, - private val navigationEndpointHandler: NavigationEndpointHandler, -) : YouTubeItemViewHolder(viewGroup, R.layout.item_youtube_list) { - val itemDetails: ItemDetailsLookup.ItemDetails? - get() = if (binding.item !is ArtistItem) { - object : ItemDetailsLookup.ItemDetails() { - override fun getPosition(): Int = absoluteAdapterPosition - override fun getSelectionKey(): String? = binding.item?.id - } - } else null - - override fun bind(item: YTItem) = bind(item, false) - - fun bind(item: YTItem, isSelected: Boolean) { - binding.item = item - if (item is SongItem && item.index != null) { - binding.index.text = item.index - } - binding.secondaryLine.isVisible = !item.subtitle.isNullOrEmpty() - binding.root.setOnClickListener { - navigationEndpointHandler.handle(item.navigationEndpoint, item) - } - binding.btnMoreAction.setOnClickListener { - MenuBottomSheetDialogFragment - .newInstance(item, navigationEndpointHandler) - .show(binding.context) - } - binding.selectedIndicator.isVisible = isSelected - binding.executePendingBindings() - } - - fun onSelectionChanged(isSelected: Boolean) { - binding.selectedIndicator.isVisible = isSelected - } -} - -class YouTubeSquareItemViewHolder( - val viewGroup: ViewGroup, - private val navigationEndpointHandler: NavigationEndpointHandler, -) : YouTubeItemViewHolder(viewGroup, R.layout.item_youtube_square) { - override fun bind(item: YTItem) { - binding.item = item - val thumbnail = item.thumbnails.last() - binding.thumbnail.updateLayoutParams { - dimensionRatio = "${thumbnail.width}:${thumbnail.height}" - } - listOf(1) + listOf(1) - binding.root.setOnClickListener { - navigationEndpointHandler.handle(item.navigationEndpoint, item) - } - binding.root.setOnLongClickListener { - MenuBottomSheetDialogFragment - .newInstance(item, navigationEndpointHandler) - .show(binding.context) - true - } - binding.executePendingBindings() - } -} - -class YouTubeNavigationItemViewHolder( - val viewGroup: ViewGroup, - private val navigationEndpointHandler: NavigationEndpointHandler, -) : YouTubeViewHolder(viewGroup, R.layout.item_youtube_navigation) { - fun bind(item: NavigationItem) { - binding.item = item - when (item.icon) { - ICON_MUSIC_NEW_RELEASE -> R.drawable.ic_new_releases - ICON_TRENDING_UP -> R.drawable.ic_trending_up - ICON_STICKER_EMOTICON -> R.drawable.ic_sentiment_satisfied - ICON_EXPLORE -> R.drawable.ic_explore - else -> null - }?.let { - binding.icon.setImageResource(it) - } - binding.container.setOnClickListener { - navigationEndpointHandler.handle(item.navigationEndpoint) - } - binding.executePendingBindings() - } -} - -class YouTubeNavigationTileViewHolder( - val viewGroup: ViewGroup, - private val navigationEndpointHandler: NavigationEndpointHandler, -) : YouTubeViewHolder(viewGroup, R.layout.item_youtube_navigation_tile) { - fun bind(item: NavigationItem) { - binding.item = item - binding.card.setOnClickListener { - navigationEndpointHandler.handle(item.navigationEndpoint) - } - binding.executePendingBindings() - } -} - -class YouTubeSuggestionViewHolder( - val viewGroup: ViewGroup, - private val onFillQuery: (String) -> Unit, - private val onSearch: (String) -> Unit, - private val onRefreshSuggestions: () -> Unit, -) : YouTubeViewHolder(viewGroup, R.layout.item_youtube_suggestion) { - @OptIn(DelicateCoroutinesApi::class) - fun bind(item: SuggestionTextItem) { - binding.item = item - binding.executePendingBindings() - binding.root.setOnClickListener { onSearch(item.query) } - binding.fillTextBtn.setOnClickListener { onFillQuery(item.query) } - if (item.source == LOCAL) { - binding.deleteBtn.setOnClickListener { - GlobalScope.launch { - SongRepository(binding.context).deleteSearchHistory(item.query) - onRefreshSuggestions() - } - } - } - } -} - -class YouTubeSeparatorViewHolder( - val viewGroup: ViewGroup, -) : YouTubeViewHolder(viewGroup, R.layout.item_separator) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/base/BindingViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/base/BindingViewHolder.kt deleted file mode 100644 index 31408db97..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/base/BindingViewHolder.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zionhuang.music.ui.viewholders.base - -import android.view.ViewGroup -import androidx.annotation.LayoutRes -import androidx.databinding.ViewDataBinding -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.extensions.inflateWithBinding - -abstract class BindingViewHolder(open val binding: T) : RecyclerView.ViewHolder(binding.root) { - constructor(viewGroup: ViewGroup, @LayoutRes layoutId: Int) : this(viewGroup.inflateWithBinding(layoutId)) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/BottomSheetListener.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/BottomSheetListener.kt deleted file mode 100644 index 4fb016448..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/BottomSheetListener.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.view.View -import com.google.android.material.bottomsheet.BottomSheetBehavior - -interface BottomSheetListener { - fun onStateChanged(bottomSheet: View, @BottomSheetBehavior.State newState: Int) - fun onSlide(bottomSheet: View, slideOffset: Float) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/ExpandableTextView.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/ExpandableTextView.kt deleted file mode 100644 index f16c88674..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/ExpandableTextView.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.content.Context -import android.util.AttributeSet -import android.util.TypedValue -import androidx.appcompat.widget.AppCompatTextView - -class ExpandableTextView : AppCompatTextView { - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - - private var selectableBackground: Int = 0 - private var isExpanded = false - - init { - with(TypedValue()) { - context.theme.resolveAttribute(android.R.attr.selectableItemBackground, this, true) - selectableBackground = resourceId - setBackgroundResource(resourceId) - } - maxLines = MAX_LINE_COLLAPSED - setOnClickListener { - toggleExpand() - } - isSaveEnabled = true - } - - private fun toggleExpand() { - maxLines = if (isExpanded) MAX_LINE_COLLAPSED else Int.MAX_VALUE - isExpanded = !isExpanded - } - - companion object { - const val MAX_LINE_COLLAPSED = 3 - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/HideOnScrollFabBehavior.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/HideOnScrollFabBehavior.kt deleted file mode 100644 index 1267ca4e4..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/HideOnScrollFabBehavior.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.view.View.INVISIBLE -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.view.ViewCompat -import androidx.core.view.isVisible -import com.google.android.material.floatingactionbutton.FloatingActionButton - -class HideOnScrollFabBehavior(context: Context, attrs: AttributeSet) : FloatingActionButton.Behavior(context, attrs) { - - // changes visibility from GONE to INVISIBLE when fab is hidden because - // due to CoordinatorLayout.onStartNestedScroll() implementation - // child view's (here, fab) onStartNestedScroll won't be called anymore - // because it's visibility is GONE - private val listener = object : FloatingActionButton.OnVisibilityChangedListener() { - override fun onHidden(fab: FloatingActionButton) { - fab.visibility = INVISIBLE - } - } - - override fun onStartNestedScroll(coordinatorLayout: CoordinatorLayout, child: FloatingActionButton, directTargetChild: View, target: View, axes: Int, type: Int): Boolean = - axes == ViewCompat.SCROLL_AXIS_VERTICAL || super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, axes, type) - - override fun onNestedScroll(coordinatorLayout: CoordinatorLayout, child: FloatingActionButton, target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) { - super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed) - if (dyConsumed > 0 && child.isVisible) { - child.hide(listener) - } else if (dyConsumed < 0 && !child.isVisible) { - child.show() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/IconButton.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/IconButton.kt deleted file mode 100644 index 601a25a0b..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/IconButton.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.content.Context -import android.util.AttributeSet -import android.util.TypedValue -import androidx.appcompat.widget.AppCompatImageView - -class IconButton : AppCompatImageView { - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - with(TypedValue()) { - context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, this, true) - setBackgroundResource(resourceId) - } - isClickable = true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/InstantAutoCompleteTextView.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/InstantAutoCompleteTextView.kt deleted file mode 100644 index fc406dd82..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/InstantAutoCompleteTextView.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.content.Context -import android.graphics.Rect -import android.util.AttributeSet -import com.google.android.material.R -import com.google.android.material.textfield.MaterialAutoCompleteTextView - -class InstantAutoCompleteTextView : MaterialAutoCompleteTextView { - constructor(context: Context) : super(context) - constructor(context: Context, attributeSet: AttributeSet?) : super(context, attributeSet, R.attr.autoCompleteTextViewStyle) - constructor(context: Context, attributeSet: AttributeSet?, defStyleAttr: Int) : super(context, attributeSet, defStyleAttr) - - override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) { - super.onFocusChanged(focused, direction, previouslyFocusedRect) - if (focused && filter != null) { - performFiltering(text, 0) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/LyricsView.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/LyricsView.kt deleted file mode 100644 index 4c167df61..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/LyricsView.kt +++ /dev/null @@ -1,501 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.animation.ValueAnimator -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.os.Looper -import android.text.Layout -import android.text.StaticLayout -import android.text.TextPaint -import android.text.format.DateUtils.SECOND_IN_MILLIS -import android.util.AttributeSet -import android.view.GestureDetector -import android.view.GestureDetector.SimpleOnGestureListener -import android.view.MotionEvent -import android.view.View -import android.widget.Scroller -import androidx.core.content.ContextCompat -import androidx.core.graphics.withSave -import androidx.interpolator.view.animation.FastOutSlowInInterpolator -import com.zionhuang.music.R -import com.zionhuang.music.utils.lyrics.LyricsEntry -import com.zionhuang.music.utils.lyrics.LyricsUtils -import kotlinx.coroutines.* -import java.lang.Runnable -import kotlin.math.abs - -/** - * Modified from [RetroMusicPlayer](https://github.com/RetroMusicPlayer/RetroMusicPlayer) - */ -@SuppressLint("StaticFieldLeak") -class LyricsView @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, -) : View(context, attrs, defStyleAttr) { - private val lrcEntryList: MutableList = ArrayList() - private val lrcPaint = TextPaint() - private val timePaint = TextPaint() - private lateinit var timeFontMetrics: Paint.FontMetrics - private var dividerHeight = 0f - private var animationDuration: Long = 0 - private var unsyncedTextColor = 0 - private var normalTextColor = 0 - private var normalTextSize = 0f - private var currentTextColor = 0 - private var currentTextSize = 0f - private var animatedCurrentTextSize = 0f - private var isShowTimeline = false - private var timelineTextColor = 0 - private var timelineColor = 0 - private var timeTextColor = 0 - private var drawableWidth = 0 - private var timeTextWidth = 0 - private var defaultLabel: String = "" - private var showLabel: Boolean = false - private var lrcPadding = 0f - private var onLyricsClickListener: OnLyricsClickListener? = null - private var animator: ValueAnimator? = null - private lateinit var gestureDetector: GestureDetector - private lateinit var scroller: Scroller - private var offset = 0f - private var previousLine = -1 - private var currentLine = 0 - private var inActive = false - private var isTouching = false - private var isFling = false - private var textGravity = 0 // left/center/right - private var isSyncedLyrics = true - var immersivePaddingTop = 0 - var isPlaying = false - set(value) { - if (field == value) return - field = value - if (value && !isFling && !inActive) { - smoothScrollTo(currentLine) - } - } - private val hideTimelineRunnable = Runnable { - inActive = false - if (hasLyrics() && isSyncedLyrics && isPlaying) { - smoothScrollTo(currentLine) - } - } - - private val viewScope = CoroutineScope(Dispatchers.Main + Job()) - - private val simpleOnGestureListener = object : SimpleOnGestureListener() { - override fun onDown(e: MotionEvent): Boolean { - if (hasLyrics() && onLyricsClickListener != null) { - if (offset != getOffset(0)) { - parent.requestDisallowInterceptTouchEvent(true) - } - scroller.forceFinished(true) - removeCallbacks(hideTimelineRunnable) - isTouching = true - inActive = true - invalidate() - return true - } - return super.onDown(e) - } - - override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean { - if (offset == getOffset(0) && distanceY < 0F) { - return super.onScroll(e1, e2, distanceX, distanceY) - } - if (hasLyrics()) { - offset = (offset - distanceY).coerceIn(getOffset(lrcEntryList.size - 1), getOffset(0)) - invalidate() - parent.requestDisallowInterceptTouchEvent(true) - return true - } - return super.onScroll(e1, e2, distanceX, distanceY) - } - - override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { - if (hasLyrics()) { - scroller.fling(0, offset.toInt(), 0, velocityY.toInt(), 0, 0, getOffset(lrcEntryList.size - 1).toInt(), getOffset(0).toInt()) - isFling = true - return true - } - return super.onFling(e1, e2, velocityX, velocityY) - } - - override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - if (hasLyrics() && isSyncedLyrics) { - val line = getLineByOffset(offset + height / 2 - e.y) - val lineTime = lrcEntryList[line].time - if (onLyricsClickListener?.onLyricsClick(lineTime) == true) { - inActive = false - removeCallbacks(hideTimelineRunnable) -// previousLine = if (line != currentLine) line else -1 -// currentLine = line -// updateCurrentTextSize() -// smoothScrollTo(line) - return true - } - } else { - callOnClick() - return true - } - return super.onSingleTapConfirmed(e) - } - } - - private fun init(attrs: AttributeSet?) { - val typedArray = context.obtainStyledAttributes(attrs, R.styleable.LyricsView) - currentTextSize = typedArray.getDimension(R.styleable.LyricsView_lrcTextSize, resources.getDimension(R.dimen.lrc_text_size)) - animatedCurrentTextSize = currentTextSize - normalTextSize = typedArray.getDimension(R.styleable.LyricsView_lrcNormalTextSize, resources.getDimension(R.dimen.lrc_text_size)).takeIf { it != 0f } ?: currentTextSize - dividerHeight = typedArray.getDimension(R.styleable.LyricsView_lrcDividerHeight, resources.getDimension(R.dimen.lrc_divider_height)) - val defaultDuration = resources.getInteger(R.integer.lrc_animation_duration) - animationDuration = typedArray.getInt(R.styleable.LyricsView_lrcAnimationDuration, defaultDuration).toLong().takeIf { it > 0 } ?: defaultDuration.toLong() - unsyncedTextColor = typedArray.getColor(R.styleable.LyricsView_lrcUnsyncedTextColor, ContextCompat.getColor(context, R.color.lrc_unsynced_text_color)) - normalTextColor = typedArray.getColor(R.styleable.LyricsView_lrcNormalTextColor, ContextCompat.getColor(context, R.color.lrc_normal_text_color)) - currentTextColor = typedArray.getColor(R.styleable.LyricsView_lrcCurrentTextColor, ContextCompat.getColor(context, R.color.lrc_current_text_color)) - isShowTimeline = typedArray.getBoolean(R.styleable.LyricsView_lrcShowTimeline, false) - timelineTextColor = typedArray.getColor(R.styleable.LyricsView_lrcTimelineTextColor, ContextCompat.getColor(context, R.color.lrc_timeline_text_color)) - defaultLabel = typedArray.getString(R.styleable.LyricsView_lrcLabel).takeUnless { it.isNullOrEmpty() } ?: "Empty" - lrcPadding = typedArray.getDimension(R.styleable.LyricsView_lrcPadding, 0f) - timelineColor = typedArray.getColor(R.styleable.LyricsView_lrcTimelineColor, ContextCompat.getColor(context, R.color.lrc_timeline_color)) - val timelineHeight = typedArray.getDimension(R.styleable.LyricsView_lrcTimelineHeight, resources.getDimension(R.dimen.lrc_timeline_height)) - timeTextColor = typedArray.getColor(R.styleable.LyricsView_lrcTimeTextColor, ContextCompat.getColor(context, R.color.lrc_time_text_color)) - val timeTextSize = typedArray.getDimension(R.styleable.LyricsView_lrcTimeTextSize, resources.getDimension(R.dimen.lrc_time_text_size)) - textGravity = typedArray.getInteger(R.styleable.LyricsView_lrcTextGravity, LyricsEntry.GRAVITY_CENTER) - typedArray.recycle() - drawableWidth = resources.getDimension(R.dimen.lrc_drawable_width).toInt() - timeTextWidth = resources.getDimension(R.dimen.lrc_time_width).toInt() - lrcPaint.apply { - isAntiAlias = true - textSize = currentTextSize - textAlign = Paint.Align.LEFT - } - timePaint.apply { - isAntiAlias = true - textSize = timeTextSize - textAlign = Paint.Align.CENTER - strokeWidth = timelineHeight - strokeCap = Paint.Cap.ROUND - } - timeFontMetrics = timePaint.fontMetrics - gestureDetector = GestureDetector(context, simpleOnGestureListener).apply { - setIsLongpressEnabled(false) - } - scroller = Scroller(context) - } - - fun setNormalColor(normalColor: Int) { - normalTextColor = normalColor - postInvalidate() - } - - fun setCurrentColor(currentColor: Int) { - currentTextColor = currentColor - postInvalidate() - } - - fun setTimelineTextColor(timelineTextColor: Int) { - this.timelineTextColor = timelineTextColor - postInvalidate() - } - - fun setTimelineColor(timelineColor: Int) { - this.timelineColor = timelineColor - postInvalidate() - } - - fun setTimeTextColor(timeTextColor: Int) { - this.timeTextColor = timeTextColor - postInvalidate() - } - - fun setDraggable(draggable: Boolean, onPlayClickListener: OnLyricsClickListener?) { - this.onLyricsClickListener = if (draggable) { - requireNotNull(onPlayClickListener) { "if draggable == true, onPlayClickListener must not be null" } - onPlayClickListener - } else { - null - } - } - - fun setLabel(label: String) { - runOnUi { - defaultLabel = label - invalidate() - } - } - - fun setTextGravity(gravity: Int) { - if (textGravity == gravity) { - return - } - textGravity = gravity - lrcEntryList.forEach { lrcEntry -> - lrcEntry.init(lrcPaint, lrcWidth.toInt(), textGravity) - } - postInvalidate() - } - - fun loadLyrics(lyrics: String) { - runOnUi { - reset() - viewScope.launch(Dispatchers.IO) { - val entries = if (lyrics.startsWith("[")) { // synced - listOf(LyricsEntry(0L, "")) + LyricsUtils.parseLyrics(lyrics) - } else { // unsynced - lyrics.lines().mapIndexed { index, line -> LyricsEntry(index * 100L, line) } - } - isSyncedLyrics = lyrics.startsWith("[") - withContext(Dispatchers.Main) { - onLyricsLoaded(entries) - } - } - } - } - - fun hasLyrics(): Boolean = lrcEntryList.isNotEmpty() - - fun updateTime(time: Long, animate: Boolean = true) { - runOnUi { - if (!hasLyrics() || !isSyncedLyrics) { - return@runOnUi - } - val line = findShowLine(time + animationDuration) - if (line != currentLine) { - previousLine = currentLine - currentLine = line - updateCurrentTextSize(animate) - if (!animate) { - scroller.forceFinished(true) - } - if ((!isFling && !inActive) || !animate) { // !animate means the user is dragging the seekbar - smoothScrollTo(line, if (animate) animationDuration else 0) - } - } - } - } - - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - super.onLayout(changed, left, top, right, bottom) - if (changed) { - initEntryList() - if (hasLyrics()) { - smoothScrollTo(currentLine, 0L) - } - } - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - val centerY = height / 2 - - if (!hasLyrics() && showLabel) { - lrcPaint.color = currentTextColor - val staticLayout = StaticLayout.Builder.obtain(defaultLabel, 0, defaultLabel.length, lrcPaint, lrcWidth.toInt()) - .setAlignment(Layout.Alignment.ALIGN_CENTER) - .setLineSpacing(0f, 1f) - .setIncludePad(false).build() - drawText(canvas, staticLayout, centerY.toFloat()) - return - } - val centerLine = getCenterLine() - if (inActive && isShowTimeline) { - timePaint.color = timeTextColor - val timeText = LyricsUtils.formatTime(lrcEntryList[centerLine].time) - val timeX = (width - timeTextWidth / 2).toFloat() - val timeY = centerY - (timeFontMetrics.descent + timeFontMetrics.ascent) / 2 - canvas.drawText(timeText, timeX, timeY, timePaint) - } - canvas.translate(0f, offset) - var y = 0f - for (i in lrcEntryList.indices) { - if (i > 0) { - y += (lrcEntryList[i - 1].height + lrcEntryList[i].height) / 2 + dividerHeight - } - if (!isSyncedLyrics) { - lrcPaint.textSize = normalTextSize - lrcPaint.color = unsyncedTextColor - } else if (i == currentLine) { - lrcPaint.textSize = animatedCurrentTextSize - lrcPaint.color = currentTextColor - } else { - lrcPaint.textSize = if (i != previousLine) normalTextSize else currentTextSize - (animatedCurrentTextSize - normalTextSize) - lrcPaint.color = normalTextColor - } - drawText(canvas, lrcEntryList[i].staticLayout!!, y) - } - } - - private fun drawText(canvas: Canvas, staticLayout: StaticLayout, y: Float) { - canvas.withSave { - translate(lrcPadding, y - staticLayout.height / 2) - staticLayout.draw(this) - } - } - - private fun updateCurrentTextSize(animate: Boolean = true) { - if (animate) { - ValueAnimator.ofFloat(normalTextSize, currentTextSize).apply { - interpolator = FastOutSlowInInterpolator() - duration = animationDuration - addUpdateListener { - animatedCurrentTextSize = it.animatedValue as Float - invalidate() - } - start() - } - } else { - animatedCurrentTextSize = currentTextSize - } - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { - isTouching = false - if (hasLyrics() && isSyncedLyrics && !isFling) { - postDelayed(hideTimelineRunnable, TIMELINE_KEEP_TIME) - } - } - return gestureDetector.onTouchEvent(event) - } - - override fun computeScroll() { - if (scroller.computeScrollOffset()) { - offset = scroller.currY.toFloat() - invalidate() - } - if (isFling && scroller.isFinished) { - isFling = false - if (hasLyrics() && isSyncedLyrics && !isTouching) { - postDelayed(hideTimelineRunnable, TIMELINE_KEEP_TIME) - } - } - } - - override fun onDetachedFromWindow() { - removeCallbacks(hideTimelineRunnable) - viewScope.cancel() - super.onDetachedFromWindow() - } - - private fun onLyricsLoaded(entryList: List?) { - if (entryList != null && entryList.isNotEmpty()) { - lrcEntryList.addAll(entryList) - } - lrcEntryList.sort() - initEntryList() - invalidate() - } - - private fun initEntryList() { - if (!hasLyrics() || width == 0) return - lrcEntryList.forEach { lrcEntry -> - lrcEntry.init(lrcPaint, lrcWidth.toInt(), textGravity) - } - offset = (height / 2).toFloat() - } - - fun reset() { - endAnimation() - scroller.forceFinished(true) - inActive = false - isTouching = false - isFling = false - removeCallbacks(hideTimelineRunnable) - lrcEntryList.clear() - offset = 0f - previousLine = -1 - currentLine = 0 - invalidate() - } - - private fun smoothScrollTo(line: Int, duration: Long = animationDuration) { - val endOffset = getOffset(line) - endAnimation() - animator = ValueAnimator.ofFloat(offset, endOffset).apply { - this.duration = duration - interpolator = FastOutSlowInInterpolator() - addUpdateListener { valueAnimator -> - offset = valueAnimator.animatedValue as Float - invalidate() - } - start() - } - } - - private fun endAnimation() { - if (animator != null && animator!!.isRunning) { - animator!!.end() - } - } - - private fun findShowLine(time: Long): Int { - var left = 0 - var right = lrcEntryList.size - while (left <= right) { - val middle = (left + right) / 2 - val middleTime = lrcEntryList[middle].time - if (time < middleTime) { - right = middle - 1 - } else { - if (middle + 1 >= lrcEntryList.size || time < lrcEntryList[middle + 1].time) { - return middle - } - left = middle + 1 - } - } - return 0 - } - - private fun getCenterLine() = getLineByOffset(offset) - private fun getLineByOffset(offset: Float): Int { - var line = 0 - var minDistance = Float.MAX_VALUE - for (i in lrcEntryList.indices) { - if (abs(offset + immersivePaddingTop - getOffset(i)) < minDistance) { - minDistance = abs(offset + immersivePaddingTop - getOffset(i)) - line = i - } - } - return line - } - - private fun getOffset(line: Int): Float { - if (lrcEntryList.isEmpty()) return 0F - if (lrcEntryList[line].offset == Float.MIN_VALUE) { - var offset = ((height + immersivePaddingTop) / 2).toFloat() - for (i in 1..line) { - offset -= (lrcEntryList[i - 1].height + lrcEntryList[i].height) / 2 + dividerHeight - } - lrcEntryList[line].offset = offset - } - return lrcEntryList[line].offset - } - - private val lrcWidth: Float - get() = width - lrcPadding * 2 - - private fun runOnUi(r: Runnable) { - if (Looper.myLooper() == Looper.getMainLooper()) { - r.run() - } else { - post(r) - } - } - - fun interface OnLyricsClickListener { - fun onLyricsClick(time: Long): Boolean - } - - companion object { - private const val TIMELINE_KEEP_TIME = 4 * SECOND_IN_MILLIS - } - - init { - init(attrs) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/MediaWidgetsController.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/MediaWidgetsController.kt deleted file mode 100644 index c57cb065b..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/MediaWidgetsController.kt +++ /dev/null @@ -1,125 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.animation.ValueAnimator -import android.animation.ValueAnimator.AnimatorUpdateListener -import android.content.Context -import android.provider.Settings -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaControllerCompat -import android.support.v4.media.session.PlaybackStateCompat -import android.view.animation.LinearInterpolator -import android.widget.ProgressBar -import android.widget.TextView -import com.google.android.material.slider.Slider -import com.zionhuang.music.utils.makeTimeString - -class MediaWidgetsController( - context: Context, - private val progressBar: ProgressBar, - private val slider: Slider, - private val progressTextView: TextView, -) { - private var sliderIsTracking = false - private var mediaController: MediaControllerCompat? = null - - private var controllerCallback: ControllerCallback? = null - private var progressAnimator: ValueAnimator? = null - private var duration: Long = 0 - private val durationScale: Float = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) - - init { - slider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { - override fun onStartTrackingTouch(slider: Slider) { - sliderIsTracking = true - } - - override fun onStopTrackingTouch(slider: Slider) { - mediaController?.transportControls?.seekTo(slider.value.toLong()) - sliderIsTracking = false - } - - }) - slider.addOnChangeListener { _, value, _ -> - progressTextView.text = makeTimeString((value).toLong()) - } - } - - fun setMediaController(newController: MediaControllerCompat?) { - if (newController != null) { - controllerCallback = ControllerCallback().also { - newController.registerCallback(it) - it.onMetadataChanged(newController.metadata) - it.onPlaybackStateChanged(newController.playbackState) - } - } else if (mediaController != null) { - if (controllerCallback != null) { - mediaController!!.unregisterCallback(controllerCallback!!) - controllerCallback = null - } - } - mediaController = newController - } - - fun disconnectController() { - if (mediaController != null) { - mediaController!!.unregisterCallback(controllerCallback!!) - controllerCallback = null - mediaController = null - } - } - - private inner class ControllerCallback : MediaControllerCompat.Callback(), AnimatorUpdateListener { - override fun onPlaybackStateChanged(state: PlaybackStateCompat?) { - super.onPlaybackStateChanged(state) - state ?: return - - progressAnimator?.cancel() - progressAnimator = null - - val progress = state.position.toInt() - progressBar.progress = progress - if (slider.isEnabled) { - slider.value = progress.toFloat().coerceIn(slider.valueFrom, slider.valueTo) - } - progressTextView.text = makeTimeString(progress.toLong()) - if (state.state == PlaybackStateCompat.STATE_PLAYING) { - val timeToEnd = ((duration - progress) / state.playbackSpeed).toInt() - if (timeToEnd > 0) { - progressAnimator?.cancel() - progressAnimator = ValueAnimator.ofInt(progress, duration.toInt()).apply { - duration = (timeToEnd / durationScale).toLong() - interpolator = LinearInterpolator() - addUpdateListener(this@ControllerCallback) - start() - } - } - } - } - - override fun onMetadataChanged(metadata: MediaMetadataCompat) { - super.onMetadataChanged(metadata) - duration = metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION) - progressBar.max = duration.toInt() - if (duration <= 0L) { - slider.isEnabled = false - } else { - slider.isEnabled = true - slider.valueTo = duration.toFloat() - } - mediaController?.let { - onPlaybackStateChanged(it.playbackState) - } - } - - override fun onAnimationUpdate(animation: ValueAnimator) { - if (sliderIsTracking) { - animation.cancel() - return - } - val animatedValue = animation.animatedValue as Int - progressBar.progress = animatedValue - slider.value = animatedValue.toFloat().coerceIn(slider.valueFrom, slider.valueTo) - progressTextView.text = makeTimeString(animatedValue.toLong()) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/NavigationBarView.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/NavigationBarView.kt deleted file mode 100644 index c246cc8f4..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/NavigationBarView.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.view.View - -class NavigationBarView : View { - private val statusBarHeight: Int - @SuppressLint("InternalInsetResource") - get() = resources.getIdentifier("navigation_bar_height", "dimen", "android").takeIf { it != 0 }?.let { - resources.getDimensionPixelSize(it) - } ?: 0 - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), statusBarHeight) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/PlayPauseButton.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/PlayPauseButton.kt deleted file mode 100644 index 3c823bf1c..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/PlayPauseButton.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import androidx.appcompat.widget.AppCompatImageView -import androidx.core.content.ContextCompat -import androidx.vectordrawable.graphics.drawable.Animatable2Compat -import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat -import com.zionhuang.music.R -import com.zionhuang.music.extensions.getAnimatedVectorDrawable - - -class PlayPauseButton : AppCompatImageView { - private val playAnimation = context.getAnimatedVectorDrawable(R.drawable.avd_play_to_pause) - private val pauseAnimation = context.getAnimatedVectorDrawable(R.drawable.avd_pause_to_play) - private val playDrawable = ContextCompat.getDrawable(context, R.drawable.ic_play)!! - private val pauseDrawable = ContextCompat.getDrawable(context, R.drawable.ic_pause)!! - private val animationCallback = object : Animatable2Compat.AnimationCallback() { - override fun onAnimationEnd(drawable: Drawable?) { - when (drawable) { - playAnimation -> setImageDrawable(pauseDrawable) - pauseAnimation -> setImageDrawable(playDrawable) - } - } - } - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - val tintResId = context.obtainStyledAttributes(attrs, R.styleable.PlayPauseButton).let { - val res = it.getColor(R.styleable.PlayPauseButton_playPauseButtonTint, resources.getColor(R.color.colorInverted, context.theme)) - it.recycle() - res - } - playAnimation.setTint(tintResId) - pauseAnimation.setTint(tintResId) - playDrawable.setTint(tintResId) - pauseDrawable.setTint(tintResId) - - isClickable = true - scaleType = ScaleType.CENTER_CROP - setImageResource(R.drawable.ic_play) - } - - fun animatePlay() { - if (tag != STATE_PLAY) { - setAvd(playAnimation) - tag = STATE_PLAY - } - } - - fun animationPause() { - if (tag != STATE_PAUSE) { - setAvd(pauseAnimation) - tag = STATE_PAUSE - } - } - - private fun setAvd(avd: AnimatedVectorDrawableCompat) { - with(avd) { - setImageDrawable(this) - start() - registerAnimationCallback(animationCallback) - } - } - - companion object { - private const val STATE_PLAY = "PLAY" - private const val STATE_PAUSE = "PAUSE" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/RepeatButton.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/RepeatButton.kt deleted file mode 100644 index 1e2b2b461..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/RepeatButton.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.content.Context -import android.support.v4.media.session.PlaybackStateCompat.* -import android.util.AttributeSet -import android.util.TypedValue -import androidx.appcompat.widget.AppCompatImageView -import com.zionhuang.music.R -import com.zionhuang.music.extensions.getAnimatedVectorDrawable - -class RepeatButton : AppCompatImageView { - @RepeatMode - private var currentState = REPEAT_MODE_NONE - - private val showAnimation = context.getAnimatedVectorDrawable(R.drawable.avd_repeat_show) - private val showOneAnimation = context.getAnimatedVectorDrawable(R.drawable.avd_repeat_show_one) - private val hideAnimation = context.getAnimatedVectorDrawable(R.drawable.avd_repeat_hide) - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - with(TypedValue()) { - context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, this, true) - setBackgroundResource(resourceId) - } - isClickable = true - scaleType = ScaleType.CENTER_CROP - setImageDrawable(hideAnimation) - hideAnimation.start() - } - - fun setState(@RepeatMode state: Int) { - if (state != currentState) { - when (state) { - REPEAT_MODE_NONE, REPEAT_MODE_INVALID -> { - setImageDrawable(hideAnimation) - hideAnimation.start() - } - REPEAT_MODE_ALL, REPEAT_MODE_GROUP -> { - setImageDrawable(showAnimation) - showAnimation.start() - } - REPEAT_MODE_ONE -> { - setImageDrawable(showOneAnimation) - showOneAnimation.start() - } - } - currentState = state - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/ShuffleButton.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/ShuffleButton.kt deleted file mode 100644 index d43f01a88..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/ShuffleButton.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.util.TypedValue -import androidx.appcompat.widget.AppCompatImageView -import androidx.core.content.ContextCompat -import androidx.vectordrawable.graphics.drawable.Animatable2Compat -import com.zionhuang.music.R -import com.zionhuang.music.extensions.getAnimatedVectorDrawable - -class ShuffleButton : AppCompatImageView { - private var isActive = true - - private val shuffleDrawable = ContextCompat.getDrawable(context, R.drawable.ic_shuffle) - private val showDrawable = context.getAnimatedVectorDrawable(R.drawable.avd_shuffle_show) - private val hideDrawable = context.getAnimatedVectorDrawable(R.drawable.avd_shuffle_hide).apply { - registerAnimationCallback(object : Animatable2Compat.AnimationCallback() { - override fun onAnimationEnd(drawable: Drawable?) { - setAlpha(if (isActive) 1f else 0.5f) - setImageDrawable(showDrawable) - showDrawable.start() - } - }) - } - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - with(TypedValue()) { - context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, this, true) - setBackgroundResource(resourceId) - } - isClickable = true - scaleType = ScaleType.CENTER_CROP - setImageDrawable(shuffleDrawable) - } - - fun enable() { - if (!isActive) { - isActive = true - animateState() - } - } - - fun disable() { - if (isActive) { - isActive = false - animateState() - } - } - - private fun animateState() { - setImageDrawable(hideDrawable) - hideDrawable.start() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/SkipNextButton.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/SkipNextButton.kt deleted file mode 100644 index 8b94af1a0..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/SkipNextButton.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.util.TypedValue -import androidx.appcompat.widget.AppCompatImageView -import androidx.core.content.ContextCompat -import androidx.vectordrawable.graphics.drawable.Animatable2Compat -import com.zionhuang.music.R -import com.zionhuang.music.extensions.getAnimatedVectorDrawable - - -class SkipNextButton : AppCompatImageView { - private val skipNextDrawable = ContextCompat.getDrawable(context, R.drawable.ic_skip_next) - private val skipNextAnimation = context.getAnimatedVectorDrawable(R.drawable.avd_skip_next) - private val animationCallback = object : Animatable2Compat.AnimationCallback() { - override fun onAnimationEnd(drawable: Drawable?) { - setImageDrawable(skipNextDrawable) - } - } - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - with(TypedValue()) { - context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, this, true) - setBackgroundResource(resourceId) - } - isClickable = true - scaleType = ScaleType.CENTER_CROP - setImageDrawable(skipNextDrawable) - } - - override fun setOnClickListener(l: OnClickListener?) { - super.setOnClickListener { - with(skipNextAnimation) { - setImageDrawable(this) - start() - registerAnimationCallback(animationCallback) - } - l?.onClick(this) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/SkipPreviousButton.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/SkipPreviousButton.kt deleted file mode 100644 index ab209d475..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/SkipPreviousButton.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.util.TypedValue -import androidx.appcompat.widget.AppCompatImageView -import androidx.core.content.ContextCompat -import androidx.vectordrawable.graphics.drawable.Animatable2Compat -import com.zionhuang.music.R -import com.zionhuang.music.extensions.getAnimatedVectorDrawable - - -class SkipPreviousButton : AppCompatImageView { - private val skipPreviousDrawable = ContextCompat.getDrawable(context, R.drawable.ic_skip_previous) - private val skipPreviousAnimation = context.getAnimatedVectorDrawable(R.drawable.avd_skip_previous) - private val animationCallback = object : Animatable2Compat.AnimationCallback() { - override fun onAnimationEnd(drawable: Drawable?) { - setImageDrawable(skipPreviousDrawable) - } - } - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - with(TypedValue()) { - context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, this, true) - setBackgroundResource(resourceId) - } - isClickable = true - scaleType = ScaleType.CENTER_CROP - setImageDrawable(skipPreviousDrawable) - } - - override fun setOnClickListener(l: OnClickListener?) { - super.setOnClickListener { - with(skipPreviousAnimation) { - setImageDrawable(this) - start() - registerAnimationCallback(animationCallback) - } - l?.onClick(this) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/SortOrderImageView.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/SortOrderImageView.kt deleted file mode 100644 index ca4fd2512..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/SortOrderImageView.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import androidx.appcompat.widget.AppCompatImageView -import androidx.core.content.ContextCompat -import androidx.vectordrawable.graphics.drawable.Animatable2Compat -import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat -import com.zionhuang.music.R -import com.zionhuang.music.extensions.getAnimatedVectorDrawable - - -class SortOrderImageView : AppCompatImageView { - private var state: State = State.DOWN - private val arrowUpwardDrawable = ContextCompat.getDrawable(context, R.drawable.ic_arrow_upward) - private val arrowDownwardDrawable = ContextCompat.getDrawable(context, R.drawable.ic_arrow_downward) - private val arrowUpToDownAnimation = context.getAnimatedVectorDrawable(R.drawable.avd_arrow_up_to_down) - private val arrowDownToUpAnimation = context.getAnimatedVectorDrawable(R.drawable.avd_arrow_down_to_up) - private val animationCallback = object : Animatable2Compat.AnimationCallback() { - override fun onAnimationEnd(drawable: Drawable?) { - when (drawable) { - arrowUpToDownAnimation -> setImageDrawable(arrowDownwardDrawable) - arrowDownToUpAnimation -> setImageDrawable(arrowUpwardDrawable) - } - } - } - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - - fun animateToUp(animate: Boolean) { - if (state != State.UP) { - state = State.UP - if (animate) { - setAvd(arrowDownToUpAnimation) - } else { - setImageDrawable(arrowUpwardDrawable) - } - } - } - - fun animateToDown(animate: Boolean) { - if (state != State.DOWN) { - state = State.DOWN - if (animate) { - setAvd(arrowUpToDownAnimation) - } else { - setImageDrawable(arrowDownwardDrawable) - } - } - } - - private fun setAvd(avd: AnimatedVectorDrawableCompat) { - with(avd) { - setImageDrawable(this) - start() - registerAnimationCallback(animationCallback) - } - } - - enum class State { - UP, DOWN - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/StatusBarView.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/StatusBarView.kt deleted file mode 100644 index 4a02b1a4b..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/StatusBarView.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.view.View - -class StatusBarView : View { - private val statusBarHeight: Int - @SuppressLint("InternalInsetResource") - get() = resources.getIdentifier("status_bar_height", "dimen", "android").takeIf { it != 0 }?.let { - resources.getDimensionPixelSize(it) - } ?: 0 - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), statusBarHeight) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/ActionModeSelectionObserver.kt b/app/src/main/java/com/zionhuang/music/utils/ActionModeSelectionObserver.kt deleted file mode 100644 index 8c0b9e4c4..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/ActionModeSelectionObserver.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.zionhuang.music.utils - -import android.app.Activity -import android.view.ActionMode -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import androidx.annotation.MenuRes -import androidx.recyclerview.selection.SelectionTracker -import com.zionhuang.music.R - -class ActionModeSelectionObserver( - private val activity: Activity, - private val tracker: SelectionTracker, - @MenuRes private val menuRes: Int, - val onActionItemClicked: (MenuItem) -> Boolean, -) : SelectionTracker.SelectionObserver() { - private var actionMode: ActionMode? = null - - override fun onItemStateChanged(key: T, selected: Boolean) { - if (!tracker.hasSelection()) { - actionMode?.finish() - return - } - if (actionMode == null) { - actionMode = activity.startActionMode(object : ActionMode.Callback { - override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { - MenuInflater(activity).inflate(menuRes, menu) - return true - } - - override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean = false - - override fun onActionItemClicked(mode: ActionMode?, item: MenuItem): Boolean { - val res = onActionItemClicked(item) - mode?.finish() - return res - } - - override fun onDestroyActionMode(mode: ActionMode?) { - tracker.clearSelection() - actionMode = null - } - }) - } - actionMode?.title = activity.resources.getQuantityString( - R.plurals.n_selected, - tracker.selection.size(), - tracker.selection.size() - ) - } -} - -fun SelectionTracker.addActionModeObserver( - activity: Activity, - @MenuRes menuRes: Int, - onActionItemClicked: (MenuItem) -> Boolean, -) = addObserver( - ActionModeSelectionObserver( - activity, - this, - menuRes, - onActionItemClicked - ) -) diff --git a/app/src/main/java/com/zionhuang/music/utils/AdaptiveUtils.kt b/app/src/main/java/com/zionhuang/music/utils/AdaptiveUtils.kt deleted file mode 100644 index 8c82bdf9d..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/AdaptiveUtils.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.zionhuang.music.utils - -import android.content.Context -import android.content.res.Configuration.ORIENTATION_UNDEFINED -import android.util.DisplayMetrics -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -object AdaptiveUtils { - - enum class ScreenSize { - SMALL, MEDIUM, LARGE, XLARGE - } - - enum class ContentState { - SINGLE_PANE, DUAL_PANE - } - - private const val SMALL_SCREEN_SIZE_UPPER_THRESHOLD = 700 - private const val MEDIUM_SCREEN_SIZE_UPPER_THRESHOLD = 840 - private const val LARGE_SCREEN_SIZE_UPPER_THRESHOLD = 1024 - - private val _orientation = MutableStateFlow(ORIENTATION_UNDEFINED) - val orientation: StateFlow = _orientation - private val _screenSizeState = MutableStateFlow(ScreenSize.SMALL) - val screenSizeState: StateFlow = _screenSizeState - - private val _contentState = MutableStateFlow(ContentState.SINGLE_PANE) - val contentState: StateFlow = _contentState - - fun updateContentState(singlePane: Boolean) { - val newState = if (singlePane) ContentState.SINGLE_PANE else ContentState.DUAL_PANE - if (_contentState.value == newState) return - _contentState.value = newState - } - - fun updateScreenSize(context: Context) { - val displayMetrics: DisplayMetrics = context.resources.displayMetrics - val screenWidth = (displayMetrics.widthPixels / displayMetrics.density).toInt() - val newState = when { - screenWidth < SMALL_SCREEN_SIZE_UPPER_THRESHOLD -> ScreenSize.SMALL - screenWidth in SMALL_SCREEN_SIZE_UPPER_THRESHOLD until MEDIUM_SCREEN_SIZE_UPPER_THRESHOLD -> ScreenSize.MEDIUM - screenWidth in MEDIUM_SCREEN_SIZE_UPPER_THRESHOLD until LARGE_SCREEN_SIZE_UPPER_THRESHOLD -> ScreenSize.LARGE - else -> ScreenSize.XLARGE - } - if (_screenSizeState.value == newState) return - _screenSizeState.value = newState - } - - fun updateOrientation(context: Context) { - _orientation.value = context.resources.configuration.orientation - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt b/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt new file mode 100644 index 000000000..1b4beff72 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt @@ -0,0 +1,34 @@ +package com.zionhuang.music.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import androidx.media3.session.BitmapLoader +import coil.imageLoader +import coil.request.ImageRequest +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.guava.future + +class CoilBitmapLoader( + private val context: Context, + private val scope: CoroutineScope, +) : BitmapLoader { + override fun decodeBitmap(data: ByteArray): ListenableFuture = + scope.future(Dispatchers.IO) { + BitmapFactory.decodeByteArray(data, 0, data.size) ?: error("Could not decode image data") + } + + override fun loadBitmap(uri: Uri): ListenableFuture = + scope.future(Dispatchers.IO) { + val result = context.imageLoader.execute( + ImageRequest.Builder(context) + .data(uri) + .build() + ) + (result.drawable as BitmapDrawable).bitmap + } +} diff --git a/app/src/main/java/com/zionhuang/music/utils/ComposeDebugUtils.kt b/app/src/main/java/com/zionhuang/music/utils/ComposeDebugUtils.kt new file mode 100644 index 000000000..511d69773 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/utils/ComposeDebugUtils.kt @@ -0,0 +1,99 @@ +package com.zionhuang.music.utils + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlin.math.min + +/** + * A [Modifier] that draws a border around elements that are recomposing. The border increases in + * size and interpolates from red to green as more recompositions occur before a timeout. + */ +@Stable +fun Modifier.recomposeHighlighter(): Modifier = this.then(recomposeModifier) + +// Use a single instance + @Stable to ensure that recompositions can enable skipping optimizations +// Modifier.composed will still remember unique data per call site. +private val recomposeModifier = + Modifier.composed(inspectorInfo = debugInspectorInfo { name = "recomposeHighlighter" }) { + // The total number of compositions that have occurred. We're not using a State<> here be + // able to read/write the value without invalidating (which would cause infinite + // recomposition). + val totalCompositions = remember { arrayOf(0L) } + totalCompositions[0]++ + + // The value of totalCompositions at the last timeout. + val totalCompositionsAtLastTimeout = remember { mutableStateOf(0L) } + + // Start the timeout, and reset everytime there's a recomposition. (Using totalCompositions + // as the key is really just to cause the timer to restart every composition). + LaunchedEffect(totalCompositions[0]) { + delay(3000) + totalCompositionsAtLastTimeout.value = totalCompositions[0] + } + + Modifier.drawWithCache { + onDrawWithContent { + // Draw actual content. + drawContent() + + // Below is to draw the highlight, if necessary. A lot of the logic is copied from + // Modifier.border + val numCompositionsSinceTimeout = + totalCompositions[0] - totalCompositionsAtLastTimeout.value + + val hasValidBorderParams = size.minDimension > 0f + if (!hasValidBorderParams || numCompositionsSinceTimeout <= 0) { + return@onDrawWithContent + } + + val (color, strokeWidthPx) = + when (numCompositionsSinceTimeout) { + // We need at least one composition to draw, so draw the smallest border + // color in blue. + 1L -> Color.Blue to 1f + // 2 compositions is _probably_ okay. + 2L -> Color.Green to 2.dp.toPx() + // 3 or more compositions before timeout may indicate an issue. lerp the + // color from yellow to red, and continually increase the border size. + else -> { + lerp( + Color.Yellow.copy(alpha = 0.8f), + Color.Red.copy(alpha = 0.5f), + min(1f, (numCompositionsSinceTimeout - 1).toFloat() / 100f) + ) to numCompositionsSinceTimeout.toInt().dp.toPx() + } + } + + val halfStroke = strokeWidthPx / 2 + val topLeft = Offset(halfStroke, halfStroke) + val borderSize = Size(size.width - strokeWidthPx, size.height - strokeWidthPx) + + val fillArea = (strokeWidthPx * 2) > size.minDimension + val rectTopLeft = if (fillArea) Offset.Zero else topLeft + val size = if (fillArea) size else borderSize + val style = if (fillArea) Fill else Stroke(strokeWidthPx) + + drawRect( + brush = SolidColor(color), + topLeft = rectTopLeft, + size = size, + style = style + ) + } + } + } diff --git a/app/src/main/java/com/zionhuang/music/utils/DataStore.kt b/app/src/main/java/com/zionhuang/music/utils/DataStore.kt new file mode 100644 index 000000000..dae632f41 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/utils/DataStore.kt @@ -0,0 +1,106 @@ +package com.zionhuang.music.utils + +import android.content.Context +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.zionhuang.music.extensions.toEnum +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.properties.ReadOnlyProperty + +val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +operator fun DataStore.get(key: Preferences.Key): T? = + runBlocking(Dispatchers.IO) { + data.first()[key] + } + +fun DataStore.get(key: Preferences.Key, defaultValue: T): T = + runBlocking(Dispatchers.IO) { + data.first()[key] ?: defaultValue + } + +fun preference( + context: Context, + key: Preferences.Key, + defaultValue: T, +) = ReadOnlyProperty { _, _ -> context.dataStore[key] ?: defaultValue } + +inline fun > enumPreference( + context: Context, + key: Preferences.Key, + defaultValue: T, +) = ReadOnlyProperty { _, _ -> context.dataStore[key].toEnum(defaultValue) } + +@Composable +fun rememberPreference( + key: Preferences.Key, + defaultValue: T, +): MutableState { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val state = remember { + context.dataStore.data + .map { it[key] ?: defaultValue } + .distinctUntilChanged() + }.collectAsState(context.dataStore[key] ?: defaultValue) + + return remember { + object : MutableState { + override var value: T + get() = state.value + set(value) { + coroutineScope.launch { + context.dataStore.edit { + it[key] = value + } + } + } + + override fun component1() = value + override fun component2(): (T) -> Unit = { value = it } + } + } +} + +@Composable +inline fun > rememberEnumPreference( + key: Preferences.Key, + defaultValue: T, +): MutableState { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val initialValue = context.dataStore[key].toEnum(defaultValue = defaultValue) + val state = remember { + context.dataStore.data + .map { it[key].toEnum(defaultValue = defaultValue) } + .distinctUntilChanged() + }.collectAsState(initialValue) + + return remember { + object : MutableState { + override var value: T + get() = state.value + set(value) { + coroutineScope.launch { + context.dataStore.edit { + it[key] = value.name + } + } + } + + override fun component1() = value + override fun component2(): (T) -> Unit = { value = it } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/utils/DownloadProgressLiveData.kt b/app/src/main/java/com/zionhuang/music/utils/DownloadProgressLiveData.kt deleted file mode 100644 index bbbdd93f0..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/DownloadProgressLiveData.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.zionhuang.music.utils - -import android.app.DownloadManager -import android.app.DownloadManager.* -import android.content.Context -import androidx.core.content.getSystemService -import androidx.lifecycle.LiveData -import com.zionhuang.music.extensions.get -import com.zionhuang.music.models.DownloadProgress -import kotlinx.coroutines.* -import kotlin.coroutines.CoroutineContext - -/** - * Modified by - * https://gist.github.com/FhdAlotaibi/678eb1f4fa94475daf74ac491874fc0e - */ -class DownloadProgressLiveData( - context: Context, - private val downloadId: Long, -) : LiveData(), CoroutineScope { - private val downloadManager = context.getSystemService()!! - private val job = Job() - override val coroutineContext: CoroutineContext get() = Dispatchers.IO + job - - override fun onActive() { - super.onActive() - launch { - while (isActive) { - downloadManager.query(Query().setFilterById(downloadId)).use { cursor -> - if (cursor.moveToFirst()) { - val status = cursor.get(COLUMN_STATUS) - postValue(DownloadProgress(status, cursor[COLUMN_BYTES_DOWNLOADED_SO_FAR], cursor[COLUMN_TOTAL_SIZE_BYTES])) - if (status == STATUS_SUCCESSFUL || status == STATUS_FAILED) cancel() - } - } - delay(INTERVAL) - } - } - } - - override fun onInactive() { - super.onInactive() - job.cancel() - } - - companion object { - const val INTERVAL: Long = 500L - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/DownloadProgressMapLiveData.kt b/app/src/main/java/com/zionhuang/music/utils/DownloadProgressMapLiveData.kt deleted file mode 100644 index ded8a0ab4..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/DownloadProgressMapLiveData.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.zionhuang.music.utils - -import android.app.DownloadManager -import android.app.DownloadManager.* -import android.content.Context -import androidx.core.content.getSystemService -import androidx.lifecycle.LiveData -import com.zionhuang.music.db.entities.DownloadEntity -import com.zionhuang.music.extensions.forEach -import com.zionhuang.music.extensions.get -import com.zionhuang.music.models.DownloadProgress -import kotlinx.coroutines.* -import kotlin.coroutines.CoroutineContext - -/** - * Modified by - * https://gist.github.com/FhdAlotaibi/678eb1f4fa94475daf74ac491874fc0e - */ -class DownloadProgressMapLiveData( - context: Context, - downloadEntities: List, -) : LiveData>(emptyMap()), CoroutineScope { - private val dlIdToSongId = downloadEntities.associate { it.id to it.songId } - private val dlIds = downloadEntities.map { it.id }.toMutableList() - private val downloadManager = context.getSystemService()!! - private val job = Job() - override val coroutineContext: CoroutineContext get() = Dispatchers.IO + job - - override fun onActive() { - super.onActive() - if (dlIds.isEmpty()) return - launch { - while (isActive) { - val result = mutableMapOf() - downloadManager.query(Query().setFilterById(*dlIds.toLongArray())).forEach { - val id = get(COLUMN_ID) - val status = get(COLUMN_STATUS) - result[dlIdToSongId[id]!!] = DownloadProgress( - status = get(COLUMN_STATUS), - currentBytes = get(COLUMN_BYTES_DOWNLOADED_SO_FAR), - totalBytes = get(COLUMN_TOTAL_SIZE_BYTES) - ) - if (status == STATUS_SUCCESSFUL || status == STATUS_FAILED) dlIds.remove(id) - } - postValue(result) - delay(INTERVAL) - } - } - } - - override fun onInactive() { - super.onInactive() - job.cancel() - } - - companion object { - const val INTERVAL: Long = 500L - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/InfoCache.kt b/app/src/main/java/com/zionhuang/music/utils/InfoCache.kt deleted file mode 100644 index 83c62d7c5..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/InfoCache.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.zionhuang.music.utils - -import androidx.collection.LruCache -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.withContext -import java.util.concurrent.TimeUnit.HOURS -import java.util.concurrent.TimeUnit.MILLISECONDS - -object InfoCache { - private const val MAX_ITEMS_ON_CACHE = 60 - - /** - * Trim the cache to this size. - */ - private const val TRIM_CACHE_TO = 30 - - private val LRU_CACHE = LruCache(MAX_ITEMS_ON_CACHE) - - private fun keyOf(url: String): String = url - - private fun removeStaleCache() { - LRU_CACHE.snapshot().forEach { (key, data) -> - if (data != null && data.isExpired) { - LRU_CACHE.remove(key) - } - } - } - - fun getInfo(key: String): Any? { - val data = LRU_CACHE[key] ?: return null - if (data.isExpired) { - LRU_CACHE.remove(key) - return null - } - return data.info - } - - private fun getFromKey(id: String): Any? = synchronized(LRU_CACHE) { - getInfo(keyOf(id)) - } - - fun putInfo(id: String, info: Any, expirationMillis: Long = MILLISECONDS.convert(1, HOURS)) { - synchronized(LRU_CACHE) { - val data = CacheData(info, expirationMillis) - LRU_CACHE.put(keyOf(id), data) - } - } - - fun removeInfo(id: String) { - synchronized(LRU_CACHE) { LRU_CACHE.remove(keyOf(id)) } - } - - fun clearCache() = synchronized(LRU_CACHE) { - LRU_CACHE.evictAll() - } - - fun trimCache() = synchronized(LRU_CACHE) { - removeStaleCache() - LRU_CACHE.trimToSize(TRIM_CACHE_TO) - } - - val size: Int - get() = synchronized(LRU_CACHE) { - LRU_CACHE.size() - } - - suspend fun checkCache(id: String, forceReload: Boolean = false, loadFromNetwork: suspend () -> T): T = - if (!forceReload) { - loadFromCache(id) - } else { - null - } ?: withContext(IO) { - loadFromNetwork().also { - putInfo(id, it) - } - } - - @Suppress("UNCHECKED_CAST") - private fun loadFromCache(id: String): T? = getFromKey(id) as? T - - private class CacheData(val info: Any, timeoutMillis: Long) { - private val expireTimestamp: Long = System.currentTimeMillis() + timeoutMillis - val isExpired: Boolean get() = System.currentTimeMillis() > expireTimestamp - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/KeyboardUtil.kt b/app/src/main/java/com/zionhuang/music/utils/KeyboardUtil.kt deleted file mode 100644 index 279c65621..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/KeyboardUtil.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.zionhuang.music.utils - -import android.app.Activity -import android.view.inputmethod.InputMethodManager -import android.view.inputmethod.InputMethodManager.RESULT_UNCHANGED_SHOWN -import android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT -import android.widget.EditText -import androidx.core.content.getSystemService - -object KeyboardUtil { - fun showKeyboard(activity: Activity, editText: EditText) { - if (editText.requestFocus()) { - val imm = activity.getSystemService()!! - imm.showSoftInput(editText, SHOW_IMPLICIT) - } - } - - fun hideKeyboard(activity: Activity, editText: EditText) { - val imm = activity.getSystemService()!! - imm.hideSoftInputFromWindow(editText.windowToken, RESULT_UNCHANGED_SHOWN) - editText.clearFocus() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/NavigationEndpointHandler.kt b/app/src/main/java/com/zionhuang/music/utils/NavigationEndpointHandler.kt deleted file mode 100644 index 4bc6c958c..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/NavigationEndpointHandler.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.zionhuang.music.utils - -import android.content.Context -import android.content.Intent -import android.content.Intent.ACTION_SEND -import android.content.Intent.EXTRA_TEXT -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_SHORT -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.transition.MaterialSharedAxis -import com.zionhuang.innertube.models.* -import com.zionhuang.music.R -import com.zionhuang.music.extensions.exceptionHandler -import com.zionhuang.music.playback.MediaSessionConnection -import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.activities.MainActivity -import com.zionhuang.music.ui.fragments.dialogs.ChoosePlaylistDialog -import com.zionhuang.music.ui.fragments.songs.ArtistSongsFragmentArgs -import com.zionhuang.music.ui.fragments.songs.PlaylistSongsFragmentArgs -import com.zionhuang.music.ui.fragments.youtube.YouTubeBrowseFragmentDirections.openYouTubeBrowseFragment -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -@OptIn(DelicateCoroutinesApi::class) -open class NavigationEndpointHandler(val fragment: Fragment) { - val mainActivity: MainActivity - get() = fragment.requireActivity() as MainActivity - val context: Context - get() = fragment.requireContext() - private val songRepository by lazy { SongRepository(fragment.requireContext()) } - - fun handle(navigationEndpoint: NavigationEndpoint?, item: YTItem? = null) = navigationEndpoint?.endpoint?.let { handle(it, item) } - - fun handle(endpoint: Endpoint, item: YTItem? = null) = when (endpoint) { - is WatchEndpoint -> { - MediaSessionConnection.binder?.songPlayer?.playQueue(YouTubeQueue(endpoint, item)) - (fragment.requireActivity() as? MainActivity)?.showBottomSheet() - } - is WatchPlaylistEndpoint -> { - MediaSessionConnection.binder?.songPlayer?.playQueue(YouTubeQueue(endpoint.toWatchEndpoint(), item)) - (fragment.requireActivity() as? MainActivity)?.showBottomSheet() - } - is BrowseEndpoint -> { - fragment.exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - fragment.reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - fragment.findNavController().navigate(openYouTubeBrowseFragment(endpoint)) - } - is SearchEndpoint -> {} - is QueueAddEndpoint -> MediaSessionConnection.binder?.songPlayer?.handleQueueAddEndpoint(endpoint, item) - is ShareEntityEndpoint -> {} - is BrowseLocalArtistSongsEndpoint -> { - fragment.exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - fragment.reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - fragment.findNavController().navigate(R.id.artistSongsFragment, ArtistSongsFragmentArgs.Builder(endpoint.artistId).build().toBundle()) - } - } - - fun share(item: YTItem) { - val intent = Intent().apply { - action = ACTION_SEND - type = "text/plain" - putExtra(EXTRA_TEXT, item.shareLink) - } - fragment.startActivity(Intent.createChooser(intent, null)) - } - - fun playNext(item: YTItem) { - val mainContent = mainActivity.binding.mainContent - handle(item.menu.playNextEndpoint, item) - Snackbar.make(mainContent, fragment.resources.getQuantityString(when (item) { - is SongItem -> R.plurals.snackbar_song_play_next - is AlbumItem -> R.plurals.snackbar_album_play_next - is PlaylistItem -> R.plurals.snackbar_playlist_play_next - else -> throw UnsupportedOperationException() - }, 1, 1), LENGTH_SHORT).show() - } - - fun addToQueue(item: YTItem) { - val mainContent = mainActivity.binding.mainContent - handle(item.menu.addToQueueEndpoint, item) - Snackbar.make(mainContent, fragment.resources.getQuantityString(when (item) { - is SongItem -> R.plurals.snackbar_song_added_to_queue - is AlbumItem -> R.plurals.snackbar_album_added_to_queue - is PlaylistItem -> R.plurals.snackbar_playlist_added_to_queue - else -> throw UnsupportedOperationException() - }, 1, 1), LENGTH_SHORT).show() - } - - fun addToLibrary(item: YTItem) { - val mainContent = mainActivity.binding.mainContent - GlobalScope.launch(context.exceptionHandler) { - when (item) { - is SongItem -> songRepository.safeAddSong(item) - is AlbumItem -> songRepository.addAlbum(item) - is PlaylistItem -> songRepository.addPlaylist(item) - else -> {} - } - Snackbar.make(mainContent, R.string.snackbar_added_to_library, LENGTH_SHORT).show() - } - } - - fun importPlaylist(playlist: PlaylistItem) { - val mainContent = mainActivity.binding.mainContent - GlobalScope.launch(context.exceptionHandler) { - songRepository.importPlaylist(playlist) - Snackbar.make(mainContent, R.string.snackbar_playlist_imported, LENGTH_SHORT).show() - } - } - - fun addToPlaylist(item: YTItem) { - val mainContent = mainActivity.binding.mainContent - ChoosePlaylistDialog { playlist -> - GlobalScope.launch(context.exceptionHandler) { - songRepository.addYouTubeItemToPlaylist(playlist, item) - Snackbar.make(mainContent, fragment.getString(R.string.snackbar_added_to_playlist, playlist.name), LENGTH_SHORT) - .setAction(R.string.snackbar_action_view) { - fragment.exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) - fragment.reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - fragment.findNavController().navigate(R.id.playlistSongsFragment, PlaylistSongsFragmentArgs.Builder(playlist.id).build().toBundle()) - }.show() - } - }.show(fragment.childFragmentManager, null) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/NavigationTabHelper.kt b/app/src/main/java/com/zionhuang/music/utils/NavigationTabHelper.kt deleted file mode 100644 index bec152392..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/NavigationTabHelper.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.zionhuang.music.utils - -import android.content.Context -import androidx.core.content.edit -import com.zionhuang.music.R -import com.zionhuang.music.extensions.sharedPreferences - -object NavigationTabHelper { - fun getConfig(context: Context): BooleanArray = try { - context.sharedPreferences.getString(context.getString(R.string.pref_nav_tab_config), null)!! - .split(",") - .map { it == "true" } - .toBooleanArray() - } catch (e: Exception) { - BooleanArray(context.resources.getStringArray(R.array.bottom_nav_items).size) { true } - } - - fun setConfig(context: Context, enabledItems: BooleanArray) = context.sharedPreferences.edit { - putString(context.getString(R.string.pref_nav_tab_config), enabledItems.joinToString(",")) - } -} diff --git a/app/src/main/java/com/zionhuang/music/utils/OkHttpDownloader.kt b/app/src/main/java/com/zionhuang/music/utils/OkHttpDownloader.kt deleted file mode 100644 index aa22042d2..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/OkHttpDownloader.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.zionhuang.music.utils - -import com.google.gson.JsonElement -import com.zionhuang.music.extensions.parseJsonString -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request - -object OkHttpDownloader { - private val client = OkHttpClient() - - @Throws(DownloadException::class) - @Suppress("BlockingMethodInNonBlockingContext") - suspend fun downloadJson(url: String): JsonElement = withContext(IO) { - val request = Request.Builder().url(url).build() - return@withContext client.newCall(request).execute().use { response -> - if (!response.isSuccessful) throw DownloadException(response.message) - response.body!!.string().parseJsonString() - } - } - - class DownloadException(override val message: String?) : Exception(message) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/StringUtils.kt b/app/src/main/java/com/zionhuang/music/utils/StringUtils.kt new file mode 100644 index 000000000..0955967bc --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/utils/StringUtils.kt @@ -0,0 +1,30 @@ +package com.zionhuang.music.utils + +import java.math.BigInteger +import java.security.MessageDigest + +fun makeTimeString(duration: Long?): String { + if (duration == null || duration < 0) return "" + var sec = duration / 1000 + val day = sec / 86400 + sec %= 86400 + val hour = sec / 3600 + sec %= 3600 + val minute = sec / 60 + sec %= 60 + return when { + day > 0 -> "%d:%02d:%02d:%02d".format(day, hour, minute, sec) + hour > 0 -> "%d:%02d:%02d".format(hour, minute, sec) + else -> "%d:%02d".format(minute, sec) + } +} + +fun md5(str: String): String { + val md = MessageDigest.getInstance("MD5") + return BigInteger(1, md.digest(str.toByteArray())).toString(16).padStart(32, '0') +} + +fun joinByBullet(vararg str: String?) = + str.filterNot { + it.isNullOrEmpty() + }.joinToString(separator = " • ") diff --git a/app/src/main/java/com/zionhuang/music/utils/ThemeUtil.kt b/app/src/main/java/com/zionhuang/music/utils/ThemeUtil.kt deleted file mode 100644 index 63795f6b9..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/ThemeUtil.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.zionhuang.music.utils.livedata - -import androidx.annotation.StyleRes -import com.zionhuang.music.R - -object ThemeUtil { - private val colorThemeMap: Map = mapOf( - "SAKURA" to R.style.ThemeOverlay_MaterialSakura, - "MATERIAL_RED" to R.style.ThemeOverlay_MaterialRed, - "MATERIAL_PINK" to R.style.ThemeOverlay_MaterialPink, - "MATERIAL_PURPLE" to R.style.ThemeOverlay_MaterialPurple, - "MATERIAL_DEEP_PURPLE" to R.style.ThemeOverlay_MaterialDeepPurple, - "MATERIAL_INDIGO" to R.style.ThemeOverlay_MaterialIndigo, - "MATERIAL_BLUE" to R.style.ThemeOverlay_MaterialBlue, - "MATERIAL_LIGHT_BLUE" to R.style.ThemeOverlay_MaterialLightBlue, - "MATERIAL_CYAN" to R.style.ThemeOverlay_MaterialCyan, - "MATERIAL_TEAL" to R.style.ThemeOverlay_MaterialTeal, - "MATERIAL_GREEN" to R.style.ThemeOverlay_MaterialGreen, - "MATERIAL_LIGHT_GREEN" to R.style.ThemeOverlay_MaterialLightGreen, - "MATERIAL_LIME" to R.style.ThemeOverlay_MaterialLime, - "MATERIAL_YELLOW" to R.style.ThemeOverlay_MaterialYellow, - "MATERIAL_AMBER" to R.style.ThemeOverlay_MaterialAmber, - "MATERIAL_ORANGE" to R.style.ThemeOverlay_MaterialOrange, - "MATERIAL_DEEP_ORANGE" to R.style.ThemeOverlay_MaterialDeepOrange, - "MATERIAL_BROWN" to R.style.ThemeOverlay_MaterialBrown, - "MATERIAL_BLUE_GREY" to R.style.ThemeOverlay_MaterialBlueGrey - ) - - const val DEFAULT_THEME = "MATERIAL_BLUE" - - @StyleRes - fun getColorThemeStyleRes(theme: String) = colorThemeMap[theme]!! -} diff --git a/app/src/main/java/com/zionhuang/music/utils/Utils.kt b/app/src/main/java/com/zionhuang/music/utils/Utils.kt deleted file mode 100644 index a97ef89a2..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/Utils.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.zionhuang.music.utils - -import androidx.core.view.isVisible -import androidx.paging.LoadState -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.databinding.LayoutLoadStateBinding -import com.zionhuang.music.extensions.context -import com.zionhuang.music.models.toErrorInfo -import com.zionhuang.music.ui.activities.ErrorActivity -import java.math.BigInteger -import java.security.MessageDigest - -fun md5(str: String): String { - val md = MessageDigest.getInstance("MD5") - return BigInteger(1, md.digest(str.toByteArray())).toString(16).padStart(32, '0') -} - -fun List.joinByBullet() = filterNot { it.isNullOrEmpty() }.joinToString(separator = " • ") - -fun PagingDataAdapter.bindLoadStateLayout(binding: LayoutLoadStateBinding, isSwipeRefreshing: () -> Boolean = { false }) { - addLoadStateListener { loadState -> - if (loadState.refresh is LoadState.Error) { - binding.errorMsg.text = (loadState.refresh as LoadState.Error).error.localizedMessage - binding.btnReport.setOnClickListener { - ErrorActivity.openActivity(binding.context, (loadState.refresh as LoadState.Error).error.toErrorInfo()) - } - } - binding.errorMsg.isVisible = loadState.refresh is LoadState.Error - binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && !isSwipeRefreshing() - binding.btnRetry.isVisible = loadState.refresh is LoadState.Error - binding.btnReport.isVisible = loadState.refresh is LoadState.Error - - } - binding.btnRetry.setOnClickListener { - retry() - } -} diff --git a/app/src/main/java/com/zionhuang/music/utils/YouTubeUtils.kt b/app/src/main/java/com/zionhuang/music/utils/YouTubeUtils.kt deleted file mode 100644 index 74550e0f8..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/YouTubeUtils.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.zionhuang.music.utils - -private const val MS_FORMAT = "%1\$d:%2$02d" -private const val HMS_FORMAT = "%1\$d:%2$02d:%3$02d" - -/** - * Convert duration in seconds to formatted time string - * - * @param duration in milliseconds - * @return formatted string - */ - -fun makeTimeString(duration: Long?): String { - if (duration == null) return "0:00" - var sec = duration / 1000 - val hour = sec / 3600 - sec %= 3600 - val minute = (sec / 60).toInt() - sec %= 60 - return if (hour == 0L) MS_FORMAT.format(minute, sec) else HMS_FORMAT.format(hour, minute, sec) -} diff --git a/app/src/main/java/com/zionhuang/music/utils/livedata/SafeLiveData.kt b/app/src/main/java/com/zionhuang/music/utils/livedata/SafeLiveData.kt deleted file mode 100644 index bfad362e2..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/livedata/SafeLiveData.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.zionhuang.music.utils.livedata - -import androidx.lifecycle.LiveData - -open class SafeLiveData(value: T) : LiveData(value) { - override fun getValue(): T = super.getValue()!! -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/livedata/SafeMutableLiveData.kt b/app/src/main/java/com/zionhuang/music/utils/livedata/SafeMutableLiveData.kt deleted file mode 100644 index 1b0500039..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/livedata/SafeMutableLiveData.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.zionhuang.music.utils.livedata - -open class SafeMutableLiveData(value: T) : SafeLiveData(value) { - public override fun postValue(value: T) = super.postValue(value) - public override fun setValue(value: T) = super.setValue(value) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/lyrics/LyricsEntry.kt b/app/src/main/java/com/zionhuang/music/utils/lyrics/LyricsEntry.kt deleted file mode 100644 index 926ccc933..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/lyrics/LyricsEntry.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.zionhuang.music.utils.lyrics - -import android.text.Layout -import android.text.StaticLayout -import android.text.TextPaint - -data class LyricsEntry( - val time: Long, - val text: String, -) : Comparable { - var staticLayout: StaticLayout? = null - private set - var offset = Float.MIN_VALUE // distance to the top of [LyricsView] - val height: Int - get() = staticLayout?.height ?: 0 - - fun init(paint: TextPaint, width: Int, gravity: Int) { - staticLayout = StaticLayout.Builder.obtain(text, 0, text.length, paint, width) - .setAlignment(when (gravity) { - GRAVITY_LEFT -> Layout.Alignment.ALIGN_NORMAL - GRAVITY_CENTER -> Layout.Alignment.ALIGN_CENTER - GRAVITY_RIGHT -> Layout.Alignment.ALIGN_OPPOSITE - else -> Layout.Alignment.ALIGN_CENTER - }) - .setLineSpacing(0f, 1f) - .setIncludePad(false).build() - offset = Float.MIN_VALUE - } - - override fun compareTo(other: LyricsEntry): Int = (time - other.time).toInt() - - companion object { - const val GRAVITY_LEFT = 0 - const val GRAVITY_CENTER = 1 - const val GRAVITY_RIGHT = 2 - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/lyrics/LyricsHelper.kt b/app/src/main/java/com/zionhuang/music/utils/lyrics/LyricsHelper.kt deleted file mode 100644 index ec4ee5046..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/lyrics/LyricsHelper.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.zionhuang.music.utils.lyrics - -import android.content.Context -import android.util.LruCache -import com.zionhuang.music.db.entities.LyricsEntity -import com.zionhuang.music.lyrics.KuGouLyricsProvider -import com.zionhuang.music.lyrics.YouTubeLyricsProvider -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.repos.SongRepository - -object LyricsHelper { - private val lyricsProviders = listOf(KuGouLyricsProvider, YouTubeLyricsProvider) - - private const val MAX_CACHE_SIZE = 10 - private val cache = LruCache>(MAX_CACHE_SIZE) - - suspend fun loadLyrics(context: Context, mediaMetadata: MediaMetadata) { - val songRepository = SongRepository(context) - val cached = cache.get(mediaMetadata.id)?.firstOrNull() - if (cached != null) { - songRepository.upsert(LyricsEntity(mediaMetadata.id, cached.lyrics)) - return - } - lyricsProviders.forEach { provider -> - if (provider.isEnabled(context)) { - provider.getLyrics( - mediaMetadata.id, - mediaMetadata.title, - mediaMetadata.artists.joinToString { it.name }, - mediaMetadata.duration - ).onSuccess { lyrics -> - songRepository.upsert(LyricsEntity(mediaMetadata.id, lyrics)) - return - }.onFailure { - it.printStackTrace() - } - } - } - songRepository.upsert(LyricsEntity(mediaMetadata.id, LyricsEntity.LYRICS_NOT_FOUND)) - } - - suspend fun getAllLyrics(context: Context, mediaId: String?, songTitle: String, songArtists: String, duration: Int): List { - val cacheKey = "$songArtists-$songTitle".replace(" ", "") - return cache.get(cacheKey) ?: lyricsProviders.flatMap { provider -> - if (provider.isEnabled(context)) { - provider.getAllLyrics(mediaId, songTitle, songArtists, duration).getOrNull().orEmpty().map { - LyricsResult(provider.name, it) - } - } else { - emptyList() - } - }.also { - cache.put(cacheKey, it) - } - } - - data class LyricsResult( - val providerName: String, - val lyrics: String, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/preference/Preference.kt b/app/src/main/java/com/zionhuang/music/utils/preference/Preference.kt deleted file mode 100644 index d414298e8..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/preference/Preference.kt +++ /dev/null @@ -1,40 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package com.zionhuang.music.utils.preference - -import android.content.Context -import android.content.SharedPreferences -import androidx.annotation.StringRes -import com.zionhuang.music.extensions.* -import kotlin.reflect.KProperty - -open class Preference( - context: Context, - @StringRes private val keyId: Int, - private val defaultValue: T, -) { - protected var key: String = context.getString(keyId) - protected var sharedPreferences: SharedPreferences = context.sharedPreferences - - protected open fun getPreferenceValue(): T = sharedPreferences.get(key, defaultValue) - protected open fun setPreferenceValue(value: T) { - sharedPreferences[key] = value - } - - operator fun getValue(thisRef: Any?, property: KProperty<*>): T = getPreferenceValue() - operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) = setPreferenceValue(value) -} - -inline fun serializablePreference(context: Context, keyId: Int, defaultValue: T): Preference = object : Preference(context, keyId, defaultValue) { - override fun getPreferenceValue(): T = sharedPreferences.getSerializable(key, defaultValue)!! - override fun setPreferenceValue(value: T) { - sharedPreferences.putSerializable(key, value) - } -} - -inline fun > enumPreference(context: Context, keyId: Int, defaultValue: E): Preference = object : Preference(context, keyId, defaultValue) { - override fun getPreferenceValue(): E = sharedPreferences.getEnum(key, defaultValue) - override fun setPreferenceValue(value: E) { - sharedPreferences.putEnum(key, value) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/preference/PreferenceLiveData.kt b/app/src/main/java/com/zionhuang/music/utils/preference/PreferenceLiveData.kt deleted file mode 100644 index 82181a400..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/preference/PreferenceLiveData.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.zionhuang.music.utils.preference - -import android.content.Context -import android.content.SharedPreferences -import android.content.SharedPreferences.OnSharedPreferenceChangeListener -import androidx.annotation.StringRes -import com.zionhuang.music.extensions.get -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.utils.livedata.SafeLiveData - -open class PreferenceLiveData( - context: Context, - val key: String, - private val defValue: T, -) : SafeLiveData(defValue) { - protected val sharedPreferences: SharedPreferences = context.sharedPreferences - - constructor(context: Context, @StringRes keyId: Int, defValue: T) : this(context, context.getString(keyId), defValue) - - protected fun getPreferenceValue() = sharedPreferences.get(key, defValue) - - private val preferenceChangeListener = OnSharedPreferenceChangeListener { _, key -> - if (this.key == key) { - value = getPreferenceValue() - } - } - - override fun onActive() { - super.onActive() - value = getPreferenceValue() - sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) - } - - override fun onInactive() { - sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) - super.onInactive() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/preference/PreferenceMutableLiveData.kt b/app/src/main/java/com/zionhuang/music/utils/preference/PreferenceMutableLiveData.kt deleted file mode 100644 index bff82e26f..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/preference/PreferenceMutableLiveData.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.zionhuang.music.utils.preference - -import android.content.Context -import androidx.annotation.StringRes -import com.zionhuang.music.extensions.set - -class PreferenceMutableLiveData( - context: Context, - @StringRes private val keyId: Int, - defValue: T, -) : PreferenceLiveData(context, keyId, defValue) { - override fun postValue(value: T) { - super.postValue(value) - sharedPreferences[key] = value - } - - override fun setValue(value: T) { - super.setValue(value) - sharedPreferences[key] = value - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt new file mode 100644 index 000000000..401d27927 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt @@ -0,0 +1,58 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.pages.AlbumPage +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.AlbumWithSongs +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AlbumViewModel @Inject constructor( + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val albumId = savedStateHandle.get("albumId")!! + private val _viewState = MutableStateFlow(null) + val viewState = _viewState.asStateFlow() + val inLibrary: StateFlow = database.album(albumId) + .map { it != null } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + init { + viewModelScope.launch { + if (database.albumWithSongs(albumId).first() == null) { + YouTube.album(albumId).getOrNull()?.let { + _viewState.value = AlbumViewState.Remote(it) + } + } else { + database.albumWithSongs(albumId).collect { albumWithSongs -> + if (albumWithSongs != null) { + _viewState.value = AlbumViewState.Local(albumWithSongs) + } + } + } + } + } +} + +sealed class AlbumViewState { + data class Local( + val albumWithSongs: AlbumWithSongs, + ) : AlbumViewState() + + data class Remote( + val albumPage: AlbumPage, + ) : AlbumViewState() +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt new file mode 100644 index 000000000..e759ecc45 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt @@ -0,0 +1,61 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.BrowseEndpoint +import com.zionhuang.music.models.ItemsPage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ArtistItemsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val browseId = savedStateHandle.get("browseId")!! + private val params = savedStateHandle.get("params") + + val title = MutableStateFlow("") + val itemsPage = MutableStateFlow(null) + + init { + viewModelScope.launch { + YouTube.artistItems( + BrowseEndpoint( + browseId = browseId, + params = params + ) + ).onSuccess { artistItemsPage -> + title.value = artistItemsPage.title + itemsPage.value = ItemsPage( + items = artistItemsPage.items, + continuation = artistItemsPage.continuation + ) + }.onFailure { e -> + e.printStackTrace() + } + } + } + + fun loadMore() { + viewModelScope.launch { + val oldItemsPage = itemsPage.value ?: return@launch + val continuation = oldItemsPage.continuation ?: return@launch + YouTube.artistItemsContinuation(continuation) + .onSuccess { artistItemsContinuationPage -> + itemsPage.update { + ItemsPage( + items = (oldItemsPage.items + artistItemsContinuationPage.items).distinctBy { it.id }, + continuation = artistItemsContinuationPage.continuation + ) + } + }.onFailure { e -> + e.printStackTrace() + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt new file mode 100644 index 000000000..d90d047ed --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt @@ -0,0 +1,38 @@ +package com.zionhuang.music.viewmodels + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.pages.ArtistPage +import com.zionhuang.music.db.MusicDatabase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ArtistViewModel @Inject constructor( + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val artistId = savedStateHandle.get("artistId")!! + var artistPage by mutableStateOf(null) + val librarySongs = database.artistSongsPreview(artistId) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + init { + viewModelScope.launch { + YouTube.artist(artistId) + .onSuccess { + artistPage = it + }.onFailure { e -> + e.printStackTrace() + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt new file mode 100644 index 000000000..a1483f963 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt @@ -0,0 +1,164 @@ +package com.zionhuang.music.viewmodels + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns +import android.widget.Toast +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.YouTube.MAX_GET_QUEUE_SIZE +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.music.MainActivity +import com.zionhuang.music.R +import com.zionhuang.music.db.InternalDatabase +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.PlaylistEntity +import com.zionhuang.music.db.entities.PlaylistEntity.Companion.generatePlaylistId +import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.extensions.div +import com.zionhuang.music.extensions.zipInputStream +import com.zionhuang.music.extensions.zipOutputStream +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.MusicService +import com.zionhuang.music.playback.MusicService.Companion.PERSISTENT_QUEUE_FILE +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import javax.inject.Inject +import kotlin.system.exitProcess + +@HiltViewModel +class BackupRestoreViewModel @Inject constructor( + val database: MusicDatabase, +) : ViewModel() { + fun backup(context: Context, uri: Uri) { + runCatching { + context.applicationContext.contentResolver.openOutputStream(uri)?.use { + it.buffered().zipOutputStream().use { outputStream -> + (context.filesDir / "datastore" / SETTINGS_FILENAME).inputStream().buffered().use { inputStream -> + outputStream.putNextEntry(ZipEntry(SETTINGS_FILENAME)) + inputStream.copyTo(outputStream) + } + runBlocking(Dispatchers.IO) { + database.checkpoint() + } + FileInputStream(database.openHelper.writableDatabase.path).use { inputStream -> + outputStream.putNextEntry(ZipEntry(InternalDatabase.DB_NAME)) + inputStream.copyTo(outputStream) + } + } + } + }.onSuccess { + Toast.makeText(context, R.string.backup_create_success, Toast.LENGTH_SHORT).show() + }.onFailure { + it.printStackTrace() + Toast.makeText(context, R.string.backup_create_failed, Toast.LENGTH_SHORT).show() + } + } + + fun restore(context: Context, uri: Uri) { + runCatching { + context.applicationContext.contentResolver.openInputStream(uri)?.use { + it.zipInputStream().use { inputStream -> + var entry = inputStream.nextEntry + while (entry != null) { + when (entry.name) { + SETTINGS_FILENAME -> { + (context.filesDir / "datastore" / SETTINGS_FILENAME).outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + InternalDatabase.DB_NAME -> { + runBlocking(Dispatchers.IO) { + database.checkpoint() + } + database.close() + FileOutputStream(database.openHelper.writableDatabase.path).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } + entry = inputStream.nextEntry + } + } + } + context.stopService(Intent(context, MusicService::class.java)) + context.filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() + context.startActivity(Intent(context, MainActivity::class.java)) + exitProcess(0) + }.onFailure { + it.printStackTrace() + Toast.makeText(context, R.string.restore_failed, Toast.LENGTH_SHORT).show() + } + } + + fun import(context: Context, uri: Uri) { + runCatching { + val videoIds = mutableListOf() + context.applicationContext.contentResolver.openInputStream(uri)?.use { inputStream -> + val br = inputStream.bufferedReader() + repeat(8) { + br.readLine() + } + var line = br.readLine() + while (line != null) { + line.split(",").firstOrNull() + ?.takeIf { it.isNotEmpty() } + ?.let { + videoIds.add(it.trim()) + } + line = br.readLine() + } + } + val playlistName = context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + cursor.getString(nameIndex) + }?.removeSuffix(".csv") ?: context.getString(R.string.imported_playlist) + viewModelScope.launch { + val songs = videoIds.chunked(MAX_GET_QUEUE_SIZE).flatMap { + withContext(Dispatchers.IO) { + YouTube.queue(videoIds = it) + }.getOrNull().orEmpty() + } + database.transaction { + val playlistId = generatePlaylistId() + var position = 0 + insert( + PlaylistEntity( + id = playlistId, + name = playlistName + ) + ) + songs.map(SongItem::toMediaMetadata) + .onEach(::insert) + .forEach { + insert( + PlaylistSongMap( + playlistId = playlistId, + songId = it.id, + position = position++ + ) + ) + } + } + Toast.makeText(context, context.resources.getQuantityString(R.plurals.import_success, songs.size, playlistName, songs.size), Toast.LENGTH_SHORT).show() + } + }.onFailure { + it.printStackTrace() + Toast.makeText(context, R.string.restore_failed, Toast.LENGTH_SHORT).show() + } + } + + companion object { + const val SETTINGS_FILENAME = "settings.preferences_pb" + } +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt new file mode 100644 index 000000000..a53f6afa8 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt @@ -0,0 +1,81 @@ +package com.zionhuang.music.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED +import com.zionhuang.music.constants.DownloadedSongSortDescendingKey +import com.zionhuang.music.constants.DownloadedSongSortType +import com.zionhuang.music.constants.DownloadedSongSortTypeKey +import com.zionhuang.music.constants.SongSortDescendingKey +import com.zionhuang.music.constants.SongSortType +import com.zionhuang.music.constants.SongSortTypeKey +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID +import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID +import com.zionhuang.music.extensions.reversed +import com.zionhuang.music.extensions.toEnum +import com.zionhuang.music.playback.DownloadUtil +import com.zionhuang.music.utils.dataStore +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class BuiltInPlaylistViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + downloadUtil: DownloadUtil, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val playlistId = savedStateHandle.get("playlistId")!! + + @OptIn(ExperimentalCoroutinesApi::class) + val songs = when (playlistId) { + LIKED_PLAYLIST_ID -> context.dataStore.data + .map { + it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.likedSongs(sortType, descending) + } + + DOWNLOADED_PLAYLIST_ID -> combine( + downloadUtil.downloads.flatMapLatest { downloads -> + database.songs( + downloads.filter { (_, download) -> + download.state == STATE_COMPLETED + }.keys.toList() + ).map { songs -> + songs.map { it to downloads[it.id] } + } + }, + context.dataStore.data + .map { + it[DownloadedSongSortTypeKey].toEnum(DownloadedSongSortType.CREATE_DATE) to (it[DownloadedSongSortDescendingKey] ?: true) + } + .distinctUntilChanged() + ) { songs, (sortType, descending) -> + when (sortType) { + DownloadedSongSortType.CREATE_DATE -> songs.sortedBy { it.second?.updateTimeMs ?: 0L } + DownloadedSongSortType.NAME -> songs.sortedBy { it.first.song.title } + DownloadedSongSortType.ARTIST -> songs.sortedBy { song -> + song.first.artists.joinToString(separator = "") { it.name } + } + } + .map { it.first } + .reversed(descending) + } + + else -> error("Unknown playlist id") + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/HistoryViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/HistoryViewModel.kt new file mode 100644 index 000000000..136b32986 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/HistoryViewModel.kt @@ -0,0 +1,61 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.music.db.MusicDatabase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +@HiltViewModel +class HistoryViewModel @Inject constructor( + val database: MusicDatabase, +) : ViewModel() { + private val today = LocalDate.now() + private val thisMonday = today.with(DayOfWeek.MONDAY) + private val lastMonday = thisMonday.minusDays(7) + + val events = database.events() + .map { events -> + events.groupBy { + val date = it.event.timestamp.toLocalDate() + val daysAgo = ChronoUnit.DAYS.between(date, today).toInt() + when { + daysAgo == 0 -> DateAgo.Today + daysAgo == 1 -> DateAgo.Yesterday + date >= thisMonday -> DateAgo.ThisWeek + date >= lastMonday -> DateAgo.LastWeek + else -> DateAgo.Other(date.withDayOfMonth(1)) + } + }.toSortedMap(compareBy { dateAgo -> + when (dateAgo) { + DateAgo.Today -> 0L + DateAgo.Yesterday -> 1L + DateAgo.ThisWeek -> 2L + DateAgo.LastWeek -> 3L + is DateAgo.Other -> ChronoUnit.DAYS.between(dateAgo.date, today) + } + }) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyMap()) +} + +sealed class DateAgo { + object Today : DateAgo() + object Yesterday : DateAgo() + object ThisWeek : DateAgo() + object LastWeek : DateAgo() + class Other(val date: LocalDate) : DateAgo() { + override fun equals(other: Any?): Boolean { + if (other is Other) return date == other.date + return super.equals(other) + } + + override fun hashCode(): Int = date.hashCode() + } +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt new file mode 100644 index 000000000..9aa7de8c4 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt @@ -0,0 +1,46 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.Song +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + val database: MusicDatabase, +) : ViewModel() { + val isRefreshing = MutableStateFlow(false) + + val quickPicks = MutableStateFlow?>(null) + val newReleaseAlbums = MutableStateFlow>(emptyList()) + + private suspend fun load() { + quickPicks.value = database.quickPicks().first().shuffled().take(20) + YouTube.newReleaseAlbumsPreview().onSuccess { + newReleaseAlbums.value = it + } + } + + fun refresh() { + if (isRefreshing.value) return + viewModelScope.launch(Dispatchers.IO) { + isRefreshing.value = true + load() + isRefreshing.value = false + } + } + + init { + viewModelScope.launch(Dispatchers.IO) { + load() + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt b/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt new file mode 100644 index 000000000..c6784e8e1 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt @@ -0,0 +1,136 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.zionhuang.music.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED +import com.zionhuang.innertube.YouTube +import com.zionhuang.music.constants.* +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.extensions.toEnum +import com.zionhuang.music.playback.DownloadUtil +import com.zionhuang.music.utils.dataStore +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.time.Duration +import java.time.LocalDateTime +import javax.inject.Inject + +@HiltViewModel +class LibrarySongsViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, +) : ViewModel() { + val allSongs = context.dataStore.data + .map { + it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.songs(sortType, descending) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) +} + +@HiltViewModel +class LibraryArtistsViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, +) : ViewModel() { + val allArtists = context.dataStore.data + .map { + it[ArtistSortTypeKey].toEnum(ArtistSortType.CREATE_DATE) to (it[ArtistSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.artists(sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + init { + viewModelScope.launch { + allArtists.collect { artists -> + artists + .map { it.artist } + .filter { + it.thumbnailUrl == null || Duration.between(it.lastUpdateTime, LocalDateTime.now()) > Duration.ofDays(10) + } + .forEach { artist -> + YouTube.artist(artist.id).onSuccess { artistPage -> + database.query { + update(artist, artistPage) + } + } + } + } + } + } +} + +@HiltViewModel +class LibraryAlbumsViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, +) : ViewModel() { + val allAlbums = context.dataStore.data + .map { + it[AlbumSortTypeKey].toEnum(AlbumSortType.CREATE_DATE) to (it[AlbumSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.albums(sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) +} + +@HiltViewModel +class LibraryPlaylistsViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + downloadUtil: DownloadUtil, +) : ViewModel() { + val likedSongCount = database.likedSongsCount() + .stateIn(viewModelScope, SharingStarted.Lazily, 0) + + val downloadedSongCount = downloadUtil.downloads.map { + it.count { (_, download) -> + download.state == STATE_COMPLETED + } + } + + val allPlaylists = context.dataStore.data + .map { + it[PlaylistSortTypeKey].toEnum(PlaylistSortType.CREATE_DATE) to (it[PlaylistSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.playlists(sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) +} + +@HiltViewModel +class ArtistSongsViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val artistId = savedStateHandle.get("artistId")!! + val artist = database.artist(artistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + + val songs = context.dataStore.data + .map { + it[ArtistSongSortTypeKey].toEnum(ArtistSongSortType.CREATE_DATE) to (it[ArtistSongSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.artistSongs(artistId, sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt new file mode 100644 index 000000000..959dfe7d6 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt @@ -0,0 +1,49 @@ +package com.zionhuang.music.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.music.constants.PlaylistSongSortDescendingKey +import com.zionhuang.music.constants.PlaylistSongSortType +import com.zionhuang.music.constants.PlaylistSongSortTypeKey +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.extensions.reversed +import com.zionhuang.music.extensions.toEnum +import com.zionhuang.music.utils.dataStore +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class LocalPlaylistViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val playlistId = savedStateHandle.get("playlistId")!! + val playlist = database.playlist(playlistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + val playlistSongs = combine( + database.playlistSongs(playlistId), + context.dataStore.data + .map { + it[PlaylistSongSortTypeKey].toEnum(PlaylistSongSortType.CUSTOM) to (it[PlaylistSongSortDescendingKey] ?: true) + } + .distinctUntilChanged() + ) { songs, (sortType, sortDescending) -> + when (sortType) { + PlaylistSongSortType.CUSTOM -> songs + PlaylistSongSortType.CREATE_DATE -> songs.sortedBy { it.map.id } + PlaylistSongSortType.NAME -> songs.sortedBy { it.song.song.title } + PlaylistSongSortType.ARTIST -> songs.sortedBy { song -> + song.song.artists.joinToString { it.name } + } + }.reversed(sortDescending && sortType != PlaylistSongSortType.CUSTOM) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/LocalSearchViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/LocalSearchViewModel.kt index bd1b0f4fd..18c59a4d0 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LocalSearchViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LocalSearchViewModel.kt @@ -1,39 +1,68 @@ package com.zionhuang.music.viewmodels -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.asFlow -import com.zionhuang.music.db.entities.LocalBaseItem -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.utils.livedata.SafeMutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.* +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.* +import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) -class LocalSearchViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository(application) - val query = SafeMutableLiveData("") - val filter = SafeMutableLiveData(Filter.ALL) - val result: Flow> = query.asFlow().combine(filter.asFlow()) { query: String, filter: Filter -> +@HiltViewModel +class LocalSearchViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { + val query = MutableStateFlow("") + val filter = MutableStateFlow(LocalFilter.ALL) + + val result = combine(query, filter) { query, filter -> query to filter }.flatMapLatest { (query, filter) -> if (query.isEmpty()) { - emptyFlow() + flowOf(LocalSearchResult("", filter, emptyMap())) } else { when (filter) { - Filter.ALL -> songRepository.searchAll(query) - Filter.SONG -> songRepository.searchSongs(query) - Filter.ALBUM -> songRepository.searchAlbums(query) - Filter.ARTIST -> songRepository.searchArtists(query) - Filter.PLAYLIST -> songRepository.searchPlaylists(query) + LocalFilter.ALL -> combine( + database.searchSongs(query, PREVIEW_SIZE), + database.searchAlbums(query, PREVIEW_SIZE), + database.searchArtists(query, PREVIEW_SIZE), + database.searchPlaylists(query, PREVIEW_SIZE), + ) { songs, albums, artists, playlists -> + songs + albums + artists + playlists + } + LocalFilter.SONG -> database.searchSongs(query) + LocalFilter.ALBUM -> database.searchAlbums(query) + LocalFilter.ARTIST -> database.searchArtists(query) + LocalFilter.PLAYLIST -> database.searchPlaylists(query) + }.map { list -> + LocalSearchResult( + query = query, + filter = filter, + map = list.groupBy { + when (it) { + is Song -> LocalFilter.SONG + is Album -> LocalFilter.ALBUM + is Artist -> LocalFilter.ARTIST + is Playlist -> LocalFilter.PLAYLIST + } + }) } } - } + }.stateIn(viewModelScope, SharingStarted.Lazily, LocalSearchResult("", filter.value, emptyMap())) - enum class Filter { - ALL, SONG, ALBUM, ARTIST, PLAYLIST + companion object { + const val PREVIEW_SIZE = 3 } -} \ No newline at end of file +} + +enum class LocalFilter { + ALL, SONG, ALBUM, ARTIST, PLAYLIST +} + +data class LocalSearchResult( + val query: String, + val filter: LocalFilter, + val map: Map>, +) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt new file mode 100644 index 000000000..2e00e058b --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt @@ -0,0 +1,56 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.LyricsEntity +import com.zionhuang.music.lyrics.LyricsHelper +import com.zionhuang.music.lyrics.LyricsResult +import com.zionhuang.music.models.MediaMetadata +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +@HiltViewModel +class LyricsMenuViewModel @Inject constructor( + private val lyricsHelper: LyricsHelper, + val database: MusicDatabase, +) : ViewModel() { + private var job: Job? = null + val results = MutableStateFlow(emptyList()) + val isLoading = MutableStateFlow(false) + + fun search(mediaId: String, title: String, artist: String, duration: Int) { + isLoading.value = true + results.value = emptyList() + job?.cancel() + job = viewModelScope.launch(Dispatchers.IO) { + lyricsHelper.getAllLyrics(mediaId, title, artist, duration) { result -> + results.update { + it + result + } + } + isLoading.value = false + } + } + + fun cancelSearch() { + job?.cancel() + job = null + } + + fun refetchLyrics(mediaMetadata: MediaMetadata, lyricsEntity: LyricsEntity?) { + database.query { + lyricsEntity?.let(::delete) + val lyrics = runBlocking { + lyricsHelper.getLyrics(mediaMetadata) + } + upsert(LyricsEntity(mediaMetadata.id, lyrics)) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt new file mode 100644 index 000000000..8153e8cb6 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt @@ -0,0 +1,25 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.AlbumItem +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class NewReleaseViewModel @Inject constructor() : ViewModel() { + private val _newReleaseAlbums = MutableStateFlow>(emptyList()) + val newReleaseAlbums = _newReleaseAlbums.asStateFlow() + + init { + viewModelScope.launch { + YouTube.newReleaseAlbums().onSuccess { + _newReleaseAlbums.value = it + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt new file mode 100644 index 000000000..cd42a4676 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt @@ -0,0 +1,36 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.utils.completed +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OnlinePlaylistViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val playlistId = savedStateHandle.get("playlistId")!! + + val playlist = MutableStateFlow(null) + val playlistSongs = MutableStateFlow>(emptyList()) + + init { + viewModelScope.launch(Dispatchers.IO) { + YouTube.playlist(playlistId).completed() + .onSuccess { playlistPage -> + playlist.value = playlistPage.playlist + playlistSongs.value = playlistPage.songs + }.onFailure { e -> + e.printStackTrace() + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt new file mode 100644 index 000000000..039d36be9 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt @@ -0,0 +1,58 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.YTItem +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.SearchHistory +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class OnlineSearchSuggestionViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { + val query = MutableStateFlow("") + private val _viewState = MutableStateFlow(SearchSuggestionViewState()) + val viewState = _viewState.asStateFlow() + + init { + viewModelScope.launch { + query.flatMapLatest { query -> + if (query.isEmpty()) { + database.searchHistory().map { history -> + SearchSuggestionViewState( + history = history + ) + } + } else { + val result = YouTube.searchSuggestions(query).getOrNull() + database.searchHistory(query) + .map { it.take(3) } + .map { history -> + SearchSuggestionViewState( + history = history, + suggestions = result?.queries?.filter { query -> + history.none { it.query == query } + }.orEmpty(), + items = result?.recommendedItems.orEmpty() + ) + } + } + }.collect { + _viewState.value = it + } + } + } +} + +data class SearchSuggestionViewState( + val history: List = emptyList(), + val suggestions: List = emptyList(), + val items: List = emptyList(), +) diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt new file mode 100644 index 000000000..3baf5146a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt @@ -0,0 +1,57 @@ +package com.zionhuang.music.viewmodels + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.pages.SearchSummaryPage +import com.zionhuang.music.models.ItemsPage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OnlineSearchViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val query = savedStateHandle.get("query")!! + val filter = MutableStateFlow(null) + var summaryPage by mutableStateOf(null) + val viewStateMap = mutableStateMapOf() + + init { + viewModelScope.launch { + filter.collect { filter -> + if (filter == null) { + if (summaryPage == null) { + summaryPage = YouTube.searchSummary(query).getOrNull() + } + } else { + if (viewStateMap[filter.value] == null) { + viewStateMap[filter.value] = YouTube.search(query, filter).getOrNull()?.let { + ItemsPage(it.items, it.continuation) + } + } + } + } + } + } + + fun loadMore() { + val filter = filter.value?.value + viewModelScope.launch { + if (filter == null) return@launch + val viewState = viewStateMap[filter] ?: return@launch + val continuation = viewState.continuation + if (continuation != null) { + val searchResult = YouTube.searchContinuation(continuation).getOrNull() ?: return@launch + viewStateMap[filter] = ItemsPage((viewState.items + searchResult.items).distinctBy { it.id }, searchResult.continuation) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt deleted file mode 100644 index d5fb33561..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.app.Activity -import android.app.Application -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.MediaMetadataCompat.METADATA_KEY_MEDIA_ID -import android.support.v4.media.session.MediaControllerCompat -import android.support.v4.media.session.MediaControllerCompat.TransportControls -import android.support.v4.media.session.PlaybackStateCompat.* -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.zionhuang.music.R -import com.zionhuang.music.extensions.preferenceLiveData -import com.zionhuang.music.models.PlaybackStateData -import com.zionhuang.music.playback.MediaSessionConnection -import com.zionhuang.music.playback.queues.Queue -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.activities.MainActivity -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch - -@OptIn(ExperimentalCoroutinesApi::class) -class PlaybackViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository(application) - val transportControls: TransportControls? get() = MediaSessionConnection.transportControls - - val mediaMetadata = MediaSessionConnection.mediaMetadata - val playbackState = MediaSessionConnection.playbackState.map { playbackState -> - if (MediaSessionConnection.mediaController != null && playbackState != null) PlaybackStateData.from(MediaSessionConnection.mediaController!!, playbackState) - else PlaybackStateData() - }.stateIn(viewModelScope, SharingStarted.Lazily, PlaybackStateData()) - val queueTitle = MediaSessionConnection.queueTitle - val queueItems = MediaSessionConnection.queueItems - - val playerVolume: Flow = MediaSessionConnection.isConnected.flatMapLatest { - MediaSessionConnection.binder?.songPlayer?.playerVolume ?: emptyFlow() - } - val currentSong = mediaMetadata.flatMapLatest { mediaMetadata -> - songRepository.getSongById(mediaMetadata?.getString(METADATA_KEY_MEDIA_ID)).flow - } - val currentSongFormat = mediaMetadata.flatMapLatest { mediaMetadata -> - songRepository.getSongFormat(mediaMetadata?.getString(METADATA_KEY_MEDIA_ID)).getFlow() - } - val currentLyrics = mediaMetadata.flatMapLatest { mediaMetadata -> - songRepository.getLyrics(mediaMetadata?.getString(METADATA_KEY_MEDIA_ID)) - }.stateIn(viewModelScope, SharingStarted.Lazily, null) - val showLyrics = preferenceLiveData(R.string.pref_show_lyrics, false) - - val position = MutableStateFlow(0L) - val duration = MutableStateFlow(0L) - private var lastPositionJob: Job? = null - - val mediaController: MediaControllerCompat? get() = MediaSessionConnection.mediaController - - init { - viewModelScope.launch { - MediaSessionConnection.playbackState.collectLatest { state -> - lastPositionJob?.cancel() - position.value = state?.position ?: 0 - if (state?.state == STATE_PLAYING) { - lastPositionJob = viewModelScope.launch { - while (true) { - MediaSessionConnection.binder?.songPlayer?.player?.currentPosition?.let { - position.value = it - } - delay(100) - } - } - } - } - } - viewModelScope.launch { - MediaSessionConnection.mediaMetadata.collectLatest { metadata -> - duration.value = metadata?.getLong(MediaMetadataCompat.METADATA_KEY_DURATION) ?: -1 - } - } - } - - fun togglePlayPause() { - if (MediaSessionConnection.playbackState.value?.state == STATE_PLAYING) { - MediaSessionConnection.transportControls?.pause() - } else { - MediaSessionConnection.transportControls?.play() - } - } - - fun toggleShuffleMode() { - MediaSessionConnection.mediaController?.let { mediaController -> - when (mediaController.shuffleMode) { - SHUFFLE_MODE_NONE -> mediaController.transportControls.setShuffleMode(SHUFFLE_MODE_ALL) - SHUFFLE_MODE_ALL -> mediaController.transportControls.setShuffleMode(SHUFFLE_MODE_NONE) - } - } - } - - fun toggleRepeatMode() { - MediaSessionConnection.mediaController?.let { mediaController -> - when (mediaController.repeatMode) { - REPEAT_MODE_NONE, REPEAT_MODE_INVALID -> mediaController.transportControls.setRepeatMode(REPEAT_MODE_ALL) - REPEAT_MODE_ALL, REPEAT_MODE_GROUP -> mediaController.transportControls.setRepeatMode(REPEAT_MODE_ONE) - REPEAT_MODE_ONE -> mediaController.transportControls.setRepeatMode(REPEAT_MODE_NONE) - } - } - } - - fun playQueue(activity: Activity, queue: Queue) { - MediaSessionConnection.binder?.songPlayer?.playQueue(queue) - (activity as? MainActivity)?.showBottomSheet() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt deleted file mode 100644 index 61dcfa65c..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import com.zionhuang.music.db.entities.* -import com.zionhuang.music.models.sortInfo.AlbumSortInfoPreference -import com.zionhuang.music.models.sortInfo.ArtistSortInfoPreference -import com.zionhuang.music.models.sortInfo.PlaylistSortInfoPreference -import com.zionhuang.music.models.sortInfo.SongSortInfoPreference -import com.zionhuang.music.repos.SongRepository -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map - -@OptIn(ExperimentalCoroutinesApi::class) -class SongsViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository(application) - - val allSongsFlow = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllSongs(sortInfo).flow - }.map { list -> - listOf(SongHeader(songRepository.getSongCount(), SongSortInfoPreference.currentInfo)) + list - } - - val allArtistsFlow = ArtistSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllArtists(sortInfo).flow - }.map { list -> - listOf(ArtistHeader(songRepository.getArtistCount(), ArtistSortInfoPreference.currentInfo)) + list - } - - val allAlbumsFlow = AlbumSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllAlbums(sortInfo).flow - }.map { list -> - listOf(AlbumHeader(songRepository.getAlbumCount(), AlbumSortInfoPreference.currentInfo)) + list - } - - val allPlaylistsFlow = combine( - songRepository.getLikedSongCount().map { LikedPlaylist(it) }, - songRepository.getDownloadedSongCount().map { DownloadedPlaylist(it) }, - PlaylistSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllPlaylists(sortInfo).flow - } - ) { likedPlaylist, downloadedPlaylist, playlists -> - listOf( - PlaylistHeader(playlists.size + 2, PlaylistSortInfoPreference.currentInfo), - likedPlaylist, - downloadedPlaylist - ) + playlists - } - - fun getArtistSongsAsFlow(artistId: String) = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getArtistSongs(artistId, sortInfo).flow - }.map { list -> - listOf(SongHeader(songRepository.getArtistSongCount(artistId), SongSortInfoPreference.currentInfo)) + list - } - - fun getPlaylistSongsAsFlow(playlistId: String) = songRepository.getPlaylistSongs(playlistId).flow.map { list -> - listOf(PlaylistSongHeader(list.size, list.sumOf { it.song.duration.toLong() })) + list - } - - fun getLikedSongsAsFlow() = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getLikedSongs(sortInfo).flow - }.map { list -> - listOf(SongHeader(list.size, SongSortInfoPreference.currentInfo)) + list - } - - fun getDownloadedSongsAsFlow() = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getDownloadedSongs(sortInfo).flow - }.map { list -> - listOf(SongHeader(list.size, SongSortInfoPreference.currentInfo)) + list - } -} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt new file mode 100644 index 000000000..499a08417 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt @@ -0,0 +1,42 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.music.db.MusicDatabase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.Duration +import java.time.LocalDateTime +import javax.inject.Inject + +@HiltViewModel +class StatsViewModel @Inject constructor( + val database: MusicDatabase, +) : ViewModel() { + val mostPlayedSongs = database.mostPlayedSongs() + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val mostPlayedArtists = database.mostPlayedArtists() + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + init { + viewModelScope.launch { + mostPlayedArtists.collect { artists -> + artists + .map { it.artist } + .filter { + it.thumbnailUrl == null || Duration.between(it.lastUpdateTime, LocalDateTime.now()) > Duration.ofDays(10) + } + .forEach { artist -> + YouTube.artist(artist.id).onSuccess { artistPage -> + database.query { + update(artist, artistPage) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt deleted file mode 100644 index 2705ad114..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope -import com.zionhuang.innertube.models.SuggestionTextItem -import com.zionhuang.innertube.models.SuggestionTextItem.SuggestionSource.LOCAL -import com.zionhuang.innertube.models.YTBaseItem -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.repos.YouTubeRepository -import kotlinx.coroutines.launch - -class SuggestionViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository(application) - private val youTubeRepository = YouTubeRepository(application) - val suggestions = MutableLiveData>(emptyList()) - - fun fetchSuggestions(query: String?) = viewModelScope.launch { - if (query.isNullOrEmpty()) { - suggestions.postValue(songRepository.getAllSearchHistory().map { SuggestionTextItem(it.query, LOCAL) }) - } else { - val history = songRepository.getSearchHistory(query).map { - SuggestionTextItem(it.query, LOCAL) - } - val ytSuggestions = try { - youTubeRepository.getSuggestions(query).filter { item -> - item !is SuggestionTextItem || history.find { it.query == item.query } == null - } - } catch (e: Exception) { - e.printStackTrace() - // Fix incorrect visitorData - // comment out because now YouTube Music doesn't give us suggestions if we're not logged in -// if (e is MissingFieldException) { -// // Reset visitorData -// YouTube.generateVisitorData().getOrNull()?.let { -// getApplication().sharedPreferences.edit { -// putString(getApplication().getString(R.string.pref_visitor_data), it) -// } -// YouTube.visitorData = it -// } -// } - emptyList() - } - suggestions.postValue(history + ytSuggestions) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt deleted file mode 100644 index b181c9e21..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.CreationExtras -import androidx.paging.* -import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.models.* -import com.zionhuang.music.repos.YouTubeRepository -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.withContext - -class YouTubeBrowseViewModel(application: Application, private val browseEndpoint: BrowseEndpoint) : AndroidViewModel(application) { - private val youTubeRepository = YouTubeRepository(application) - private var _albumName: String? = null - val albumName: String? get() = _albumName - private var _albumSongs: List? = null - val albumSongs: List? get() = _albumSongs - - val pagingData = Pager(PagingConfig(pageSize = 20)) { - if (browseEndpoint.isAlbumEndpoint) { - object : PagingSource, YTBaseItem>() { - override suspend fun load(params: LoadParams>): LoadResult, YTBaseItem> = withContext(IO) { - YouTube.browse(browseEndpoint).map { result -> - _albumName = (result.items.firstOrNull() as? AlbumOrPlaylistHeader)?.name - LoadResult.Page, YTBaseItem>( - data = result.items.also { items -> - _albumSongs = items.filterIsInstance().map { - // replaced album audio items have inappropriate navigation endpoint, so we remove it and let clicking handled by fragment - it.copy(navigationEndpoint = NavigationEndpoint()) - } - }, - prevKey = null, - nextKey = null - ) - }.getOrElse { throwable -> - LoadResult.Error(throwable) - } - } - - override fun getRefreshKey(state: PagingState, YTBaseItem>): List? = null - } - } else { - youTubeRepository.browse(browseEndpoint) - } - }.flow.cachedIn(viewModelScope) -} - -class YouTubeBrowseViewModelFactory( - val application: Application, - private val browseEndpoint: BrowseEndpoint, -) : ViewModelProvider.AndroidViewModelFactory(application) { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class, extras: CreationExtras): T = - YouTubeBrowseViewModel(application, browseEndpoint) as T -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeSearchViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeSearchViewModel.kt deleted file mode 100644 index af8e69c16..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeSearchViewModel.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.app.Application -import androidx.lifecycle.* -import androidx.lifecycle.viewmodel.CreationExtras -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.cachedIn -import com.zionhuang.innertube.YouTube -import com.zionhuang.music.repos.YouTubeRepository - -class YouTubeSearchViewModel(application: Application, query: String) : AndroidViewModel(application) { - private val youTubeRepository = YouTubeRepository(application) - val filter = MutableLiveData(null) - - val pagingData = Pager(PagingConfig(pageSize = 20)) { - filter.value.let { - if (it == null) youTubeRepository.searchAll(query) - else youTubeRepository.search(query, it) - } - }.flow.cachedIn(viewModelScope) -} - -class YouTubeSearchViewModelFactory(val application: Application, val query: String) : ViewModelProvider.AndroidViewModelFactory(application) { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class, extras: CreationExtras): T = - YouTubeSearchViewModel(application, query) as T -} \ No newline at end of file diff --git a/app/src/main/res/drawable/add.xml b/app/src/main/res/drawable/add.xml new file mode 100644 index 000000000..d8931fe9d --- /dev/null +++ b/app/src/main/res/drawable/add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/album.xml b/app/src/main/res/drawable/album.xml new file mode 100644 index 000000000..52147ae4c --- /dev/null +++ b/app/src/main/res/drawable/album.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/arrow_back.xml similarity index 51% rename from app/src/main/res/drawable/ic_pause.xml rename to app/src/main/res/drawable/arrow_back.xml index 7e8a3e2ad..b47229cdb 100644 --- a/app/src/main/res/drawable/ic_pause.xml +++ b/app/src/main/res/drawable/arrow_back.xml @@ -1,10 +1,9 @@ + android:viewportWidth="960" + android:viewportHeight="960"> + android:pathData="M480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520L537,744L480,800Z" /> diff --git a/app/src/main/res/drawable/ic_expand_more.xml b/app/src/main/res/drawable/arrow_downward.xml similarity index 51% rename from app/src/main/res/drawable/ic_expand_more.xml rename to app/src/main/res/drawable/arrow_downward.xml index 7ade5b875..caa5be8f3 100644 --- a/app/src/main/res/drawable/ic_expand_more.xml +++ b/app/src/main/res/drawable/arrow_downward.xml @@ -1,10 +1,9 @@ + android:viewportWidth="960" + android:viewportHeight="960"> + android:pathData="M480,800L160,480L216,423L440,647L440,160L520,160L520,647L744,423L800,480L480,800Z" /> diff --git a/app/src/main/res/drawable/arrow_top_left.xml b/app/src/main/res/drawable/arrow_top_left.xml new file mode 100644 index 000000000..d57c5334b --- /dev/null +++ b/app/src/main/res/drawable/arrow_top_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/arrow_upward.xml b/app/src/main/res/drawable/arrow_upward.xml new file mode 100644 index 000000000..3dfa31b18 --- /dev/null +++ b/app/src/main/res/drawable/arrow_upward.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_artist.xml b/app/src/main/res/drawable/artist.xml similarity index 95% rename from app/src/main/res/drawable/ic_artist.xml rename to app/src/main/res/drawable/artist.xml index c7f3087c0..0401939a4 100644 --- a/app/src/main/res/drawable/ic_artist.xml +++ b/app/src/main/res/drawable/artist.xml @@ -2,7 +2,6 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_arrow_up_to_down.xml b/app/src/main/res/drawable/avd_arrow_up_to_down.xml deleted file mode 100644 index 5131fe276..000000000 --- a/app/src/main/res/drawable/avd_arrow_up_to_down.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_pause_to_play.xml b/app/src/main/res/drawable/avd_pause_to_play.xml deleted file mode 100644 index a41e6a837..000000000 --- a/app/src/main/res/drawable/avd_pause_to_play.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_play_to_pause.xml b/app/src/main/res/drawable/avd_play_to_pause.xml deleted file mode 100644 index 8b2fabf0f..000000000 --- a/app/src/main/res/drawable/avd_play_to_pause.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_repeat_hide.xml b/app/src/main/res/drawable/avd_repeat_hide.xml deleted file mode 100644 index 213883648..000000000 --- a/app/src/main/res/drawable/avd_repeat_hide.xml +++ /dev/null @@ -1,219 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_repeat_show.xml b/app/src/main/res/drawable/avd_repeat_show.xml deleted file mode 100644 index 926e83860..000000000 --- a/app/src/main/res/drawable/avd_repeat_show.xml +++ /dev/null @@ -1,202 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_repeat_show_one.xml b/app/src/main/res/drawable/avd_repeat_show_one.xml deleted file mode 100644 index a0a42f012..000000000 --- a/app/src/main/res/drawable/avd_repeat_show_one.xml +++ /dev/null @@ -1,210 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_shuffle_hide.xml b/app/src/main/res/drawable/avd_shuffle_hide.xml deleted file mode 100644 index bc5bf4f52..000000000 --- a/app/src/main/res/drawable/avd_shuffle_hide.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_shuffle_show.xml b/app/src/main/res/drawable/avd_shuffle_show.xml deleted file mode 100644 index f9232f53e..000000000 --- a/app/src/main/res/drawable/avd_shuffle_show.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_skip_next.xml b/app/src/main/res/drawable/avd_skip_next.xml deleted file mode 100644 index b4243941e..000000000 --- a/app/src/main/res/drawable/avd_skip_next.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_skip_previous.xml b/app/src/main/res/drawable/avd_skip_previous.xml deleted file mode 100644 index 26628642d..000000000 --- a/app/src/main/res/drawable/avd_skip_previous.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/backup.xml b/app/src/main/res/drawable/backup.xml new file mode 100644 index 000000000..e596e5107 --- /dev/null +++ b/app/src/main/res/drawable/backup.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/bedtime.xml b/app/src/main/res/drawable/bedtime.xml new file mode 100644 index 000000000..b0f466ce1 --- /dev/null +++ b/app/src/main/res/drawable/bedtime.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/btn_play_pause_background.xml b/app/src/main/res/drawable/btn_play_pause_background.xml deleted file mode 100644 index 2119fa885..000000000 --- a/app/src/main/res/drawable/btn_play_pause_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/buymeacoffee.xml b/app/src/main/res/drawable/buymeacoffee.xml new file mode 100644 index 000000000..b667cb25e --- /dev/null +++ b/app/src/main/res/drawable/buymeacoffee.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/cached.xml b/app/src/main/res/drawable/cached.xml new file mode 100644 index 000000000..d62c4e713 --- /dev/null +++ b/app/src/main/res/drawable/cached.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/casino.xml b/app/src/main/res/drawable/casino.xml new file mode 100644 index 000000000..f95dbb099 --- /dev/null +++ b/app/src/main/res/drawable/casino.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/clear_all.xml b/app/src/main/res/drawable/clear_all.xml new file mode 100644 index 000000000..d90d0b3bf --- /dev/null +++ b/app/src/main/res/drawable/clear_all.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/close.xml b/app/src/main/res/drawable/close.xml new file mode 100644 index 000000000..da02c5325 --- /dev/null +++ b/app/src/main/res/drawable/close.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_navigate_next.xml b/app/src/main/res/drawable/contrast.xml similarity index 61% rename from app/src/main/res/drawable/ic_navigate_next.xml rename to app/src/main/res/drawable/contrast.xml index 4d08c5f21..3ec9d8981 100644 --- a/app/src/main/res/drawable/ic_navigate_next.xml +++ b/app/src/main/res/drawable/contrast.xml @@ -1,10 +1,9 @@ + android:pathData="M12,22c5.52,0 10,-4.48 10,-10S17.52,2 12,2S2,6.48 2,12S6.48,22 12,22zM13,4.07c3.94,0.49 7,3.85 7,7.93s-3.05,7.44 -7,7.93V4.07z" /> diff --git a/app/src/main/res/drawable/dark_mode.xml b/app/src/main/res/drawable/dark_mode.xml new file mode 100644 index 000000000..59390b753 --- /dev/null +++ b/app/src/main/res/drawable/dark_mode.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/delete.xml b/app/src/main/res/drawable/delete.xml new file mode 100644 index 000000000..7122c17ef --- /dev/null +++ b/app/src/main/res/drawable/delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/download.xml b/app/src/main/res/drawable/download.xml new file mode 100644 index 000000000..4b05bc3a4 --- /dev/null +++ b/app/src/main/res/drawable/download.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/drag_handle.xml b/app/src/main/res/drawable/drag_handle.xml new file mode 100644 index 000000000..e604410c9 --- /dev/null +++ b/app/src/main/res/drawable/drag_handle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/edit.xml b/app/src/main/res/drawable/edit.xml new file mode 100644 index 000000000..6b01a9285 --- /dev/null +++ b/app/src/main/res/drawable/edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/equalizer.xml b/app/src/main/res/drawable/equalizer.xml new file mode 100644 index 000000000..e6cd00e43 --- /dev/null +++ b/app/src/main/res/drawable/equalizer.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/error.xml b/app/src/main/res/drawable/error.xml new file mode 100644 index 000000000..785630cd1 --- /dev/null +++ b/app/src/main/res/drawable/error.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/expand_less.xml b/app/src/main/res/drawable/expand_less.xml new file mode 100644 index 000000000..eae03a722 --- /dev/null +++ b/app/src/main/res/drawable/expand_less.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/expand_more.xml b/app/src/main/res/drawable/expand_more.xml new file mode 100644 index 000000000..ba2142ab0 --- /dev/null +++ b/app/src/main/res/drawable/expand_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/explicit.xml b/app/src/main/res/drawable/explicit.xml new file mode 100644 index 000000000..fce57ae0a --- /dev/null +++ b/app/src/main/res/drawable/explicit.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/favorite.xml b/app/src/main/res/drawable/favorite.xml new file mode 100644 index 000000000..ac89942d3 --- /dev/null +++ b/app/src/main/res/drawable/favorite.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/favorite_border.xml b/app/src/main/res/drawable/favorite_border.xml new file mode 100644 index 000000000..b1550b134 --- /dev/null +++ b/app/src/main/res/drawable/favorite_border.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/github.xml similarity index 96% rename from app/src/main/res/drawable/ic_github.xml rename to app/src/main/res/drawable/github.xml index a2c5ec6ed..11742d01e 100644 --- a/app/src/main/res/drawable/ic_github.xml +++ b/app/src/main/res/drawable/github.xml @@ -1,7 +1,6 @@ + + diff --git a/app/src/main/res/drawable/history.xml b/app/src/main/res/drawable/history.xml new file mode 100644 index 000000000..9a80afdc8 --- /dev/null +++ b/app/src/main/res/drawable/history.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/home.xml b/app/src/main/res/drawable/home.xml new file mode 100644 index 000000000..c88d0882e --- /dev/null +++ b/app/src/main/res/drawable/home.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml deleted file mode 100644 index 9a267e200..000000000 --- a/app/src/main/res/drawable/ic_add.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_album.xml b/app/src/main/res/drawable/ic_album.xml deleted file mode 100644 index 6fefe0c60..000000000 --- a/app/src/main/res/drawable/ic_album.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml deleted file mode 100644 index beafea395..000000000 --- a/app/src/main/res/drawable/ic_arrow_back.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_downward.xml b/app/src/main/res/drawable/ic_arrow_downward.xml deleted file mode 100644 index 6ce6a15a7..000000000 --- a/app/src/main/res/drawable/ic_arrow_downward.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_top_left.xml b/app/src/main/res/drawable/ic_arrow_top_left.xml deleted file mode 100644 index 20b455730..000000000 --- a/app/src/main/res/drawable/ic_arrow_top_left.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_upward.xml b/app/src/main/res/drawable/ic_arrow_upward.xml deleted file mode 100644 index 2e8706f01..000000000 --- a/app/src/main/res/drawable/ic_arrow_upward.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_backup.xml b/app/src/main/res/drawable/ic_backup.xml deleted file mode 100644 index 6a948a95c..000000000 --- a/app/src/main/res/drawable/ic_backup.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_bedtime.xml b/app/src/main/res/drawable/ic_bedtime.xml deleted file mode 100644 index ac2928a51..000000000 --- a/app/src/main/res/drawable/ic_bedtime.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_cached.xml b/app/src/main/res/drawable/ic_cached.xml deleted file mode 100644 index 914ee06bd..000000000 --- a/app/src/main/res/drawable/ic_cached.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_clear_all.xml b/app/src/main/res/drawable/ic_clear_all.xml deleted file mode 100644 index 43f847bad..000000000 --- a/app/src/main/res/drawable/ic_clear_all.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml deleted file mode 100644 index b8af066c7..000000000 --- a/app/src/main/res/drawable/ic_close.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_content_copy.xml b/app/src/main/res/drawable/ic_content_copy.xml deleted file mode 100644 index 7a75806e5..000000000 --- a/app/src/main/res/drawable/ic_content_copy.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_dark_mode.xml b/app/src/main/res/drawable/ic_dark_mode.xml deleted file mode 100644 index e52c5baff..000000000 --- a/app/src/main/res/drawable/ic_dark_mode.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml deleted file mode 100644 index 2c0afcc56..000000000 --- a/app/src/main/res/drawable/ic_delete.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_drag_handle.xml b/app/src/main/res/drawable/ic_drag_handle.xml deleted file mode 100644 index 2082dd28e..000000000 --- a/app/src/main/res/drawable/ic_drag_handle.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml deleted file mode 100644 index 042e31432..000000000 --- a/app/src/main/res/drawable/ic_edit.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_equalizer.xml b/app/src/main/res/drawable/ic_equalizer.xml deleted file mode 100644 index ebb2e42c6..000000000 --- a/app/src/main/res/drawable/ic_equalizer.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_error.xml b/app/src/main/res/drawable/ic_error.xml deleted file mode 100644 index 8552d365f..000000000 --- a/app/src/main/res/drawable/ic_error.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_explore.xml b/app/src/main/res/drawable/ic_explore.xml deleted file mode 100644 index 802944f85..000000000 --- a/app/src/main/res/drawable/ic_explore.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_favorite.xml b/app/src/main/res/drawable/ic_favorite.xml deleted file mode 100644 index 060920bb7..000000000 --- a/app/src/main/res/drawable/ic_favorite.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_favorite_border.xml b/app/src/main/res/drawable/ic_favorite_border.xml deleted file mode 100644 index 394c069ae..000000000 --- a/app/src/main/res/drawable/ic_favorite_border.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml deleted file mode 100644 index d90081c79..000000000 --- a/app/src/main/res/drawable/ic_folder.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_graphic_eq.xml b/app/src/main/res/drawable/ic_graphic_eq.xml deleted file mode 100644 index dd2c0f479..000000000 --- a/app/src/main/res/drawable/ic_graphic_eq.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml deleted file mode 100644 index 5224228b8..000000000 --- a/app/src/main/res/drawable/ic_history.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml deleted file mode 100644 index 3b2c4ccb4..000000000 --- a/app/src/main/res/drawable/ic_home.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_image.xml b/app/src/main/res/drawable/ic_image.xml deleted file mode 100644 index ef088446a..000000000 --- a/app/src/main/res/drawable/ic_image.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml deleted file mode 100644 index 57d32e994..000000000 --- a/app/src/main/res/drawable/ic_info.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_input.xml b/app/src/main/res/drawable/ic_input.xml deleted file mode 100644 index 2f7eecaf0..000000000 --- a/app/src/main/res/drawable/ic_input.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_keyboard_voice.xml b/app/src/main/res/drawable/ic_keyboard_voice.xml deleted file mode 100644 index a1c5ba04f..000000000 --- a/app/src/main/res/drawable/ic_keyboard_voice.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_language.xml b/app/src/main/res/drawable/ic_language.xml deleted file mode 100644 index 0cbccad48..000000000 --- a/app/src/main/res/drawable/ic_language.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index f4198ab18..000000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_library_add.xml b/app/src/main/res/drawable/ic_library_add.xml deleted file mode 100644 index 76d8be14d..000000000 --- a/app/src/main/res/drawable/ic_library_add.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_library_add_check.xml b/app/src/main/res/drawable/ic_library_add_check.xml deleted file mode 100644 index 645994b97..000000000 --- a/app/src/main/res/drawable/ic_library_add_check.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_lyrics.xml b/app/src/main/res/drawable/ic_lyrics.xml deleted file mode 100644 index cfeea76f2..000000000 --- a/app/src/main/res/drawable/ic_lyrics.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_manage_search.xml b/app/src/main/res/drawable/ic_manage_search.xml deleted file mode 100644 index ae1e3c7db..000000000 --- a/app/src/main/res/drawable/ic_manage_search.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_more_horiz.xml b/app/src/main/res/drawable/ic_more_horiz.xml deleted file mode 100644 index 49ba88449..000000000 --- a/app/src/main/res/drawable/ic_more_horiz.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml deleted file mode 100644 index 7847a372d..000000000 --- a/app/src/main/res/drawable/ic_more_vert.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_music_note.xml b/app/src/main/res/drawable/ic_music_note.xml deleted file mode 100644 index 13c018e0f..000000000 --- a/app/src/main/res/drawable/ic_music_note.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_new_releases.xml b/app/src/main/res/drawable/ic_new_releases.xml deleted file mode 100644 index 1f74b4aef..000000000 --- a/app/src/main/res/drawable/ic_new_releases.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml deleted file mode 100644 index d295693be..000000000 --- a/app/src/main/res/drawable/ic_notification.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_notifications.xml b/app/src/main/res/drawable/ic_notifications.xml deleted file mode 100644 index c5dc28d68..000000000 --- a/app/src/main/res/drawable/ic_notifications.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_offline_pin.xml b/app/src/main/res/drawable/ic_offline_pin.xml deleted file mode 100644 index 4fc20bffe..000000000 --- a/app/src/main/res/drawable/ic_offline_pin.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_open_in_full.xml b/app/src/main/res/drawable/ic_open_in_full.xml deleted file mode 100644 index 2451fb59d..000000000 --- a/app/src/main/res/drawable/ic_open_in_full.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_palette.xml b/app/src/main/res/drawable/ic_palette.xml deleted file mode 100644 index 52011771f..000000000 --- a/app/src/main/res/drawable/ic_palette.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml deleted file mode 100644 index a37adc634..000000000 --- a/app/src/main/res/drawable/ic_person.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_place.xml b/app/src/main/res/drawable/ic_place.xml deleted file mode 100644 index 67aad5c1e..000000000 --- a/app/src/main/res/drawable/ic_place.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_playlist_add.xml b/app/src/main/res/drawable/ic_playlist_add.xml deleted file mode 100644 index df857b0f4..000000000 --- a/app/src/main/res/drawable/ic_playlist_add.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_playlist_play.xml b/app/src/main/res/drawable/ic_playlist_play.xml deleted file mode 100644 index c3a8b0909..000000000 --- a/app/src/main/res/drawable/ic_playlist_play.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_queue_music.xml b/app/src/main/res/drawable/ic_queue_music.xml deleted file mode 100644 index 4240383fc..000000000 --- a/app/src/main/res/drawable/ic_queue_music.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_radio.xml b/app/src/main/res/drawable/ic_radio.xml deleted file mode 100644 index 1ababbd82..000000000 --- a/app/src/main/res/drawable/ic_radio.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml deleted file mode 100644 index f15525471..000000000 --- a/app/src/main/res/drawable/ic_refresh.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_repeat.xml b/app/src/main/res/drawable/ic_repeat.xml deleted file mode 100644 index 7a4a756e2..000000000 --- a/app/src/main/res/drawable/ic_repeat.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_repeat_one.xml b/app/src/main/res/drawable/ic_repeat_one.xml deleted file mode 100644 index e26ae699b..000000000 --- a/app/src/main/res/drawable/ic_repeat_one.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_restore.xml b/app/src/main/res/drawable/ic_restore.xml deleted file mode 100644 index 5224228b8..000000000 --- a/app/src/main/res/drawable/ic_restore.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_save_alt.xml b/app/src/main/res/drawable/ic_save_alt.xml deleted file mode 100644 index b4e47467c..000000000 --- a/app/src/main/res/drawable/ic_save_alt.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml deleted file mode 100644 index 355fda7f3..000000000 --- a/app/src/main/res/drawable/ic_search.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_security.xml b/app/src/main/res/drawable/ic_security.xml deleted file mode 100644 index c9a007df1..000000000 --- a/app/src/main/res/drawable/ic_security.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_sentiment_satisfied.xml b/app/src/main/res/drawable/ic_sentiment_satisfied.xml deleted file mode 100644 index 1d5c49c09..000000000 --- a/app/src/main/res/drawable/ic_sentiment_satisfied.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml deleted file mode 100644 index 24a5623cd..000000000 --- a/app/src/main/res/drawable/ic_settings.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_settings_backup_restore.xml b/app/src/main/res/drawable/ic_settings_backup_restore.xml deleted file mode 100644 index eb454de30..000000000 --- a/app/src/main/res/drawable/ic_settings_backup_restore.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml deleted file mode 100644 index 48f6a8490..000000000 --- a/app/src/main/res/drawable/ic_share.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_shuffle.xml b/app/src/main/res/drawable/ic_shuffle.xml deleted file mode 100644 index 3e52f67f1..000000000 --- a/app/src/main/res/drawable/ic_shuffle.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_shuffle_on.xml b/app/src/main/res/drawable/ic_shuffle_on.xml deleted file mode 100644 index 3bb8d2afa..000000000 --- a/app/src/main/res/drawable/ic_shuffle_on.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml deleted file mode 100644 index 993680b81..000000000 --- a/app/src/main/res/drawable/ic_skip_next.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_skip_previous.xml b/app/src/main/res/drawable/ic_skip_previous.xml deleted file mode 100644 index f8c3bab71..000000000 --- a/app/src/main/res/drawable/ic_skip_previous.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_storage.xml b/app/src/main/res/drawable/ic_storage.xml deleted file mode 100644 index bab38ce55..000000000 --- a/app/src/main/res/drawable/ic_storage.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_sync.xml b/app/src/main/res/drawable/ic_sync.xml deleted file mode 100644 index d4dba44ae..000000000 --- a/app/src/main/res/drawable/ic_sync.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_tab.xml b/app/src/main/res/drawable/ic_tab.xml deleted file mode 100644 index b9a7b92f4..000000000 --- a/app/src/main/res/drawable/ic_tab.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_testing.xml b/app/src/main/res/drawable/ic_testing.xml deleted file mode 100644 index eba9bdf66..000000000 --- a/app/src/main/res/drawable/ic_testing.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_trending_up.xml b/app/src/main/res/drawable/ic_trending_up.xml deleted file mode 100644 index 973f2aa98..000000000 --- a/app/src/main/res/drawable/ic_trending_up.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_view_column.xml b/app/src/main/res/drawable/ic_view_column.xml deleted file mode 100644 index f89c4b526..000000000 --- a/app/src/main/res/drawable/ic_view_column.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_visibility.xml b/app/src/main/res/drawable/ic_visibility.xml deleted file mode 100644 index 9cd541120..000000000 --- a/app/src/main/res/drawable/ic_visibility.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_volume_up.xml b/app/src/main/res/drawable/ic_volume_up.xml deleted file mode 100644 index 3c738b859..000000000 --- a/app/src/main/res/drawable/ic_volume_up.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/info.xml b/app/src/main/res/drawable/info.xml new file mode 100644 index 000000000..1a8889503 --- /dev/null +++ b/app/src/main/res/drawable/info.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/input.xml b/app/src/main/res/drawable/input.xml new file mode 100644 index 000000000..f9e8ecfb1 --- /dev/null +++ b/app/src/main/res/drawable/input.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/language.xml b/app/src/main/res/drawable/language.xml new file mode 100644 index 000000000..af0776a39 --- /dev/null +++ b/app/src/main/res/drawable/language.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/launcher_foreground.xml b/app/src/main/res/drawable/launcher_foreground.xml new file mode 100644 index 000000000..a1ed771fb --- /dev/null +++ b/app/src/main/res/drawable/launcher_foreground.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/launcher_monochrome.xml b/app/src/main/res/drawable/launcher_monochrome.xml new file mode 100644 index 000000000..1ef5c4783 --- /dev/null +++ b/app/src/main/res/drawable/launcher_monochrome.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/liberapay.xml b/app/src/main/res/drawable/liberapay.xml new file mode 100644 index 000000000..d296518d8 --- /dev/null +++ b/app/src/main/res/drawable/liberapay.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/library_add.xml b/app/src/main/res/drawable/library_add.xml new file mode 100644 index 000000000..8aa198e7c --- /dev/null +++ b/app/src/main/res/drawable/library_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/library_add_check.xml b/app/src/main/res/drawable/library_add_check.xml new file mode 100644 index 000000000..24a09afba --- /dev/null +++ b/app/src/main/res/drawable/library_add_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/library_music.xml b/app/src/main/res/drawable/library_music.xml new file mode 100644 index 000000000..3f5f75a00 --- /dev/null +++ b/app/src/main/res/drawable/library_music.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/location_on.xml b/app/src/main/res/drawable/location_on.xml new file mode 100644 index 000000000..461dcbe39 --- /dev/null +++ b/app/src/main/res/drawable/location_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/lock.xml b/app/src/main/res/drawable/lock.xml new file mode 100644 index 000000000..6c1357a6b --- /dev/null +++ b/app/src/main/res/drawable/lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/lock_open.xml b/app/src/main/res/drawable/lock_open.xml new file mode 100644 index 000000000..3bbfccbcf --- /dev/null +++ b/app/src/main/res/drawable/lock_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/lyrics.xml b/app/src/main/res/drawable/lyrics.xml new file mode 100644 index 000000000..24875cf16 --- /dev/null +++ b/app/src/main/res/drawable/lyrics.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/manage_search.xml b/app/src/main/res/drawable/manage_search.xml new file mode 100644 index 000000000..ba575ee09 --- /dev/null +++ b/app/src/main/res/drawable/manage_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/more_horiz.xml b/app/src/main/res/drawable/more_horiz.xml new file mode 100644 index 000000000..6e0938877 --- /dev/null +++ b/app/src/main/res/drawable/more_horiz.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/more_vert.xml b/app/src/main/res/drawable/more_vert.xml new file mode 100644 index 000000000..ddc2ea72f --- /dev/null +++ b/app/src/main/res/drawable/more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/music_note.xml b/app/src/main/res/drawable/music_note.xml new file mode 100644 index 000000000..7a0582f04 --- /dev/null +++ b/app/src/main/res/drawable/music_note.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_download.xml b/app/src/main/res/drawable/navigate_next.xml similarity index 50% rename from app/src/main/res/drawable/ic_file_download.xml rename to app/src/main/res/drawable/navigate_next.xml index 56b2e31c1..9880347df 100644 --- a/app/src/main/res/drawable/ic_file_download.xml +++ b/app/src/main/res/drawable/navigate_next.xml @@ -1,10 +1,10 @@ + android:autoMirrored="true" + android:viewportWidth="960" + android:viewportHeight="960"> + android:pathData="M376,720L320,664L504,480L320,296L376,240L616,480L376,720Z" /> diff --git a/app/src/main/res/drawable/offline.xml b/app/src/main/res/drawable/offline.xml new file mode 100644 index 000000000..802c44e73 --- /dev/null +++ b/app/src/main/res/drawable/offline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/palette.xml b/app/src/main/res/drawable/palette.xml new file mode 100644 index 000000000..a13e00f65 --- /dev/null +++ b/app/src/main/res/drawable/palette.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/pause.xml b/app/src/main/res/drawable/pause.xml new file mode 100644 index 000000000..91f4f99b0 --- /dev/null +++ b/app/src/main/res/drawable/pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/play.xml similarity index 55% rename from app/src/main/res/drawable/ic_play.xml rename to app/src/main/res/drawable/play.xml index da896608b..c34838d94 100644 --- a/app/src/main/res/drawable/ic_play.xml +++ b/app/src/main/res/drawable/play.xml @@ -1,10 +1,9 @@ + android:viewportWidth="960" + android:viewportHeight="960"> + android:pathData="M320,760L320,200L760,480L320,760Z" /> diff --git a/app/src/main/res/drawable/playlist_add.xml b/app/src/main/res/drawable/playlist_add.xml new file mode 100644 index 000000000..70c17fa68 --- /dev/null +++ b/app/src/main/res/drawable/playlist_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/playlist_play.xml b/app/src/main/res/drawable/playlist_play.xml new file mode 100644 index 000000000..9bcc6ceed --- /dev/null +++ b/app/src/main/res/drawable/playlist_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/queue_music.xml b/app/src/main/res/drawable/queue_music.xml new file mode 100644 index 000000000..3f6add945 --- /dev/null +++ b/app/src/main/res/drawable/queue_music.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/radio.xml b/app/src/main/res/drawable/radio.xml new file mode 100644 index 000000000..31fdaa336 --- /dev/null +++ b/app/src/main/res/drawable/radio.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/radio_button_checked.xml b/app/src/main/res/drawable/radio_button_checked.xml new file mode 100644 index 000000000..49e00936c --- /dev/null +++ b/app/src/main/res/drawable/radio_button_checked.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/radio_button_unchecked.xml b/app/src/main/res/drawable/radio_button_unchecked.xml new file mode 100644 index 000000000..555c26d3e --- /dev/null +++ b/app/src/main/res/drawable/radio_button_unchecked.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/repeat.xml b/app/src/main/res/drawable/repeat.xml new file mode 100644 index 000000000..51948dd58 --- /dev/null +++ b/app/src/main/res/drawable/repeat.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/repeat_one.xml b/app/src/main/res/drawable/repeat_one.xml new file mode 100644 index 000000000..d77cd74b5 --- /dev/null +++ b/app/src/main/res/drawable/repeat_one.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/replay.xml b/app/src/main/res/drawable/replay.xml new file mode 100644 index 000000000..597252032 --- /dev/null +++ b/app/src/main/res/drawable/replay.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/restore.xml b/app/src/main/res/drawable/restore.xml new file mode 100644 index 000000000..7e1e9e70d --- /dev/null +++ b/app/src/main/res/drawable/restore.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/search.xml b/app/src/main/res/drawable/search.xml new file mode 100644 index 000000000..468f65511 --- /dev/null +++ b/app/src/main/res/drawable/search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/security.xml b/app/src/main/res/drawable/security.xml new file mode 100644 index 000000000..b9d145e7c --- /dev/null +++ b/app/src/main/res/drawable/security.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/settings.xml b/app/src/main/res/drawable/settings.xml new file mode 100644 index 000000000..48db39640 --- /dev/null +++ b/app/src/main/res/drawable/settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/share.xml b/app/src/main/res/drawable/share.xml new file mode 100644 index 000000000..ac08e2921 --- /dev/null +++ b/app/src/main/res/drawable/share.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/shuffle.xml b/app/src/main/res/drawable/shuffle.xml new file mode 100644 index 000000000..1c1c923fe --- /dev/null +++ b/app/src/main/res/drawable/shuffle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/shuffle_on.xml b/app/src/main/res/drawable/shuffle_on.xml new file mode 100644 index 000000000..e18e294e4 --- /dev/null +++ b/app/src/main/res/drawable/shuffle_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/skip_next.xml b/app/src/main/res/drawable/skip_next.xml new file mode 100644 index 000000000..783320188 --- /dev/null +++ b/app/src/main/res/drawable/skip_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/skip_previous.xml b/app/src/main/res/drawable/skip_previous.xml new file mode 100644 index 000000000..6cda8024f --- /dev/null +++ b/app/src/main/res/drawable/skip_previous.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/small_icon.xml b/app/src/main/res/drawable/small_icon.xml new file mode 100644 index 000000000..3da94f137 --- /dev/null +++ b/app/src/main/res/drawable/small_icon.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/storage.xml b/app/src/main/res/drawable/storage.xml new file mode 100644 index 000000000..8be9ea5c9 --- /dev/null +++ b/app/src/main/res/drawable/storage.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/sync.xml b/app/src/main/res/drawable/sync.xml new file mode 100644 index 000000000..3efff3334 --- /dev/null +++ b/app/src/main/res/drawable/sync.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/tab.xml b/app/src/main/res/drawable/tab.xml new file mode 100644 index 000000000..b6ee503db --- /dev/null +++ b/app/src/main/res/drawable/tab.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/trending_up.xml b/app/src/main/res/drawable/trending_up.xml new file mode 100644 index 000000000..2268bbf96 --- /dev/null +++ b/app/src/main/res/drawable/trending_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/volume_up.xml b/app/src/main/res/drawable/volume_up.xml new file mode 100644 index 000000000..1e8711885 --- /dev/null +++ b/app/src/main/res/drawable/volume_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout-land/bottom_controls_sheet.xml b/app/src/main/res/layout-land/bottom_controls_sheet.xml deleted file mode 100644 index 531dcde46..000000000 --- a/app/src/main/res/layout-land/bottom_controls_sheet.xml +++ /dev/null @@ -1,336 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_error.xml b/app/src/main/res/layout/activity_error.xml deleted file mode 100755 index 96131242f..000000000 --- a/app/src/main/res/layout/activity_error.xml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - -