diff --git a/README.md b/README.md index 761340c05..87bcf6a6f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ -Make your own music library with any song on YouTube/YouTube Music. +Make your own music library with any song from YouTube Music. No ads, free, and simple. [](https://apt.izzysoft.de/fdroid/index/apk/com.zionhuang.music) @@ -15,27 +15,23 @@ No ads, free, and simple. > **Note 2:** The name of this app is temporary. It will be changed in the future. -> **Note 3:** We are currently making a change about the YouTube library. The development is in `feature/innertube` branch. Issues are put on hold until `feature/innertube` gets merged into `dev` branch. -## Description - -With this app, you're like getting a free music streaming service. You can listen to music from YouTube/YouTube Music and build your own library. What's more, songs can be downloaded for offline playback. The metadata of songs and artists are fully editable. You can also create playlists to organize your songs. The aim of _Music_ is to enable everyone to listen to music at no cost by an easy-to-use, practical and ad-free application. - -The ability to retrieve information and stream data from YouTube/YouTube Music is provided by [NewPipe Extractor](https://github.com/TeamNewPipe/NewPipeExtractor). +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 _Music_ is to enable everyone to listen to music at no cost by an easy-to-use, practical and ad-free application. ## Features ### YouTube -- No ads -- Search songs, albums, videos, playlists and channels from YouTube/YouTube Music -- Auto load more songs when playing the last 5 songs in queues from 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 -- Play and save songs from YouTube/YouTube Music +- Save songs, albums and playlists in local database - Download music for offline playback -- Edit song and artist metadata -- Create playlists in local database +- Edit song title +- Add links to your favorite YouTube Music playlists ### Player @@ -49,15 +45,16 @@ The ability to retrieve information and stream data from YouTube/YouTube Music i ## Screenshots

- - - - + + + +

- - - + + + +

## Installation diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 080659c6e..ff031b45c 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,22 +8,24 @@ plugins { id("dev.rikka.tools.materialthemebuilder") } -val newpipeVersion: String by rootProject.extra - android { - compileSdk = 31 + compileSdk = 32 buildToolsVersion = "30.0.3" defaultConfig { applicationId = "com.zionhuang.music" minSdk = 26 - targetSdk = 31 - versionCode = 10 - versionName = "0.3.3" + targetSdk = 32 + versionCode = 11 + versionName = "0.4.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + javaCompileOptions { + annotationProcessorOptions { + arguments += mapOf("room.schemaLocation" to "$projectDir/schemas") + } + } } applicationVariants.all { resValue("string", "app_version", versionName) - resValue("string", "newpipe_version", newpipeVersion) } buildTypes { getByName("release") { @@ -51,7 +53,7 @@ android { } kotlinOptions { jvmTarget = "1.8" - freeCompilerArgs += listOf("-Xopt-in=kotlin.RequiresOptIn") + freeCompilerArgs = freeCompilerArgs + listOf("-opt-in=kotlin.RequiresOptIn") } configurations.all { resolutionStrategy { @@ -62,6 +64,10 @@ android { unitTests.isIncludeAndroidResources = true unitTests.isReturnDefaultValues = true } + sourceSets { + // Adds exported schema location as test app assets. + getByName("androidTest").assets.srcDir("$projectDir/schemas") + } } materialThemeBuilder { @@ -99,63 +105,55 @@ materialThemeBuilder { } dependencies { - implementation(fileTree("dir" to "libs", "include" to "*.jar")) // Kotlin - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.2") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2") + 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.3.3") // AndroidX implementation("androidx.core:core-ktx:1.8.0") - implementation("androidx.appcompat:appcompat:1.4.2") + implementation("androidx.appcompat:appcompat:1.5.1") implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.fragment:fragment-ktx:1.4.1") + 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.4.2") - implementation("androidx.navigation:navigation-fragment-ktx:2.4.2") - implementation("androidx.navigation:navigation-ui-ktx:2.4.2") - implementation("androidx.lifecycle:lifecycle-extensions:2.2.0") - implementation("androidx.lifecycle:lifecycle-common-java8:2.4.1") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.1") - implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.4.1") + 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.6.1") + implementation("com.google.android.material:material:1.7.0-rc01") // Gson implementation("com.google.code.gson:gson:2.9.0") // ExoPlayer - implementation("com.google.android.exoplayer:exoplayer:2.17.1") - implementation("com.google.android.exoplayer:extension-mediasession:2.17.1") + implementation("com.google.android.exoplayer:exoplayer:2.18.1") + implementation("com.google.android.exoplayer:extension-mediasession: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.2") - kapt("androidx.room:room-compiler:2.4.2") - implementation("androidx.room:room-rxjava3:2.4.2") - implementation("androidx.room:room-ktx:2.4.2") - implementation("androidx.room:room-paging:2.4.2") - testImplementation("androidx.room:room-testing:2.4.2") - // NewPipe Extractor - implementation("com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751") - implementation("com.github.TeamNewPipe:NewPipeExtractor:6a858368c86bc9a55abee586eb6c733e86c26b97") + 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"))) // 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.9.3") - // Glide - implementation("com.github.bumptech.glide:glide:4.13.2") - implementation("com.github.bumptech.glide:annotations:4.13.2") - implementation("com.github.bumptech.glide:okhttp3-integration:4.13.1") - kapt("com.github.bumptech.glide:compiler:4.13.1") - // Jsoup - implementation("org.jsoup:jsoup:1.14.3") + implementation("com.squareup.okhttp3:okhttp:4.10.0") + // Coil + implementation("io.coil-kt:coil:2.2.0") // Fast Scroll implementation("me.zhanghai.android.fastscroll:library:1.1.7") // Markdown diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a11159e1e..1aa730e53 100755 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,97 +1,92 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle.kts. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - -# Rules for Glide --keep public class * implements com.bumptech.glide.module.GlideModule --keep class * extends com.bumptech.glide.module.AppGlideModule --keep class com.bumptech.glide.GeneratedAppGlideModuleImpl --keep public enum com.bumptech.glide.load.ImageHeaderParser$** { - **[] $VALUES; - public *; -} - -# Rules for NewPipe --dontobfuscate --keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } --keep class org.ocpsoft.prettytime.i18n.** { *; } - --keep class org.mozilla.javascript.** { *; } - -##---------------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. --if @kotlinx.serialization.Serializable class ** --keepclassmembers class <1> { - static <1>$Companion Companion; -} - -# Keep `serializer()` on companion objects (both default and named) of serializable classes. --if @kotlinx.serialization.Serializable class ** { - static **$* *; -} --keepclassmembers class <2>$<3> { - kotlinx.serialization.KSerializer serializer(...); -} - -# Keep `INSTANCE.serializer()` of serializable objects. --if @kotlinx.serialization.Serializable class ** { - public static ** INSTANCE; -} --keepclassmembers class <1> { - public static <1> INSTANCE; - kotlinx.serialization.KSerializer serializer(...); -} - -# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. --keepattributes RuntimeVisibleAnnotations,AnnotationDefault +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +##---------------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. +-if @kotlinx.serialization.Serializable class ** +-keepclasseswithmembers class <1> { + static <1>$Companion Companion; +} + +# Keep `serializer()` on companion objects (both default and named) of serializable classes. +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclasseswithmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep `INSTANCE.serializer()` of serializable objects. +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclasseswithmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault + +-dontwarn javax.servlet.ServletContainerInitializer +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE diff --git a/app/schemas/com.zionhuang.music.db.MusicDatabase/1.json b/app/schemas/com.zionhuang.music.db.MusicDatabase/1.json new file mode 100644 index 000000000..2043e6b30 --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.MusicDatabase/1.json @@ -0,0 +1,297 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "38686a738e9e794eca8e1f635cf072b0", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistId` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `liked` INTEGER NOT NULL, `artworkType` 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`), FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artworkType", + "columnName": "artworkType", + "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": [ + { + "name": "index_song_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_song_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_song_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_artist_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_artist_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "playlistId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlist_playlistId", + "unique": true, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "playlist_song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `idInPlaylist` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`playlistId`) 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": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idInPlaylist", + "columnName": "idInPlaylist", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlist_song_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "playlistId" + ] + }, + { + "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": [] + } + ], + "views": [], + "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, '38686a738e9e794eca8e1f635cf072b0')" + ] + } +} \ 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.MusicDatabase/2.json new file mode 100644 index 000000000..7e8403ccc --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.MusicDatabase/2.json @@ -0,0 +1,656 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "3a7db15c3d60f94f6a7acc75fad88d79", + "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, 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": false + } + ], + "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": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "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, '3a7db15c3d60f94f6a7acc75fad88d79')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/zionhuang/music/extensions/LiveDataExt.kt b/app/src/androidTest/java/com/zionhuang/music/extensions/LiveDataExt.kt deleted file mode 100644 index 07a9873c7..000000000 --- a/app/src/androidTest/java/com/zionhuang/music/extensions/LiveDataExt.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zionhuang.music.extensions - -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -@Throws(InterruptedException::class) -fun LiveData.getValueBlocking(): T? { - var value: T? = null - val latch = CountDownLatch(1) - val innerObserver = Observer { - value = it - latch.countDown() - } - observeForever(innerObserver) - latch.await(2, TimeUnit.SECONDS) - return value -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e36d67b72..ee4a568f1 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,241 +1,159 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt index eb083f38b..5c0be0e62 100644 --- a/app/src/main/java/com/zionhuang/music/App.kt +++ b/app/src/main/java/com/zionhuang/music/App.kt @@ -1,20 +1,30 @@ package com.zionhuang.music import android.app.Application -import com.zionhuang.music.utils.getPreferredContentCountry -import com.zionhuang.music.utils.getPreferredLocalization -import com.zionhuang.music.youtube.NewPipeDownloader -import org.schabi.newpipe.extractor.NewPipe +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.YouTubeLocale +import com.zionhuang.music.extensions.sharedPreferences +import com.zionhuang.music.playback.MediaSessionConnection +import java.util.* class App : Application() { override fun onCreate() { super.onCreate() INSTANCE = this - NewPipe.init( - NewPipeDownloader.init(), - getPreferredLocalization(this), - getPreferredContentCountry(this) + + val systemDefault = getString(R.string.default_localization_key) + YouTube.locale = YouTubeLocale( + gl = sharedPreferences.getString(getString(R.string.pref_content_country), systemDefault).takeIf { it != systemDefault } ?: Locale.getDefault().country, + hl = sharedPreferences.getString(getString(R.string.pref_content_language), systemDefault).takeIf { it != systemDefault } ?: Locale.getDefault().toLanguageTag() ) + if (sharedPreferences.getBoolean(getString(R.string.pref_proxy_enabled), false)) { + try { + YouTube.setProxyUrl(sharedPreferences.getString(getString(R.string.pref_proxy_url), null) ?: "") + } catch (e: Exception) { + e.printStackTrace() + } + } + MediaSessionConnection.connect(this) } companion object { diff --git a/app/src/main/java/com/zionhuang/music/constants/Constants.kt b/app/src/main/java/com/zionhuang/music/constants/Constants.kt index f6d7f9c13..0a35a07e5 100644 --- a/app/src/main/java/com/zionhuang/music/constants/Constants.kt +++ b/app/src/main/java/com/zionhuang/music/constants/Constants.kt @@ -1,15 +1,12 @@ package com.zionhuang.music.constants -import com.zionhuang.music.db.entities.Song - object Constants { - const val EMPTY_SONG_ID = "empty_song" - - const val HEADER_ITEM_ID = "\$HEADER$" - val HEADER_PLACEHOLDER_SONG = Song(id = HEADER_ITEM_ID, title = "") - const val TYPE_HEADER = 0 - const val TYPE_ITEM = 1 + 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 APP_URL = "https://github.com/z-huang/music" - const val NEWPIPE_EXTRACTOR_URL = "https://github.com/TeamNewPipe/NewPipeExtractor" } \ 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 index a964b46b8..a1b56d9a4 100644 --- a/app/src/main/java/com/zionhuang/music/constants/MediaConstants.kt +++ b/app/src/main/java/com/zionhuang/music/constants/MediaConstants.kt @@ -1,40 +1,12 @@ package com.zionhuang.music.constants -import androidx.annotation.IntDef - object MediaConstants { - const val EXTRA_QUEUE_DATA = "queue_data" - const val EXTRA_SONG_ID = "song_id" + const val EXTRA_MEDIA_METADATA = "media_metadata" + const val EXTRA_MEDIA_METADATA_ITEMS = "media_metadata_items" const val EXTRA_SONG = "song" - const val EXTRA_SONGS = "songs" - const val EXTRA_ARTIST_ID = "artist_id" const val EXTRA_ARTIST = "artist" - const val EXTRA_PLAYLIST_ID = "playlist_id" const val EXTRA_PLAYLIST = "playlist" - const val EXTRA_SEARCH_FILTER = "search_filter" - const val EXTRA_ARTWORK_TYPE = "artwork_type" - const val EXTRA_DURATION = "duration" - - @IntDef(QUEUE_NONE, QUEUE_ALL_SONG, QUEUE_PLAYLIST, QUEUE_ARTIST, QUEUE_CHANNEL, QUEUE_YT_SINGLE, QUEUE_YT_SEARCH, QUEUE_YT_PLAYLIST) - @Retention(AnnotationRetention.SOURCE) - annotation class QueueType - - const val QUEUE_NONE = 0 - const val QUEUE_ALL_SONG = 1 - const val QUEUE_PLAYLIST = 2 - const val QUEUE_ARTIST = 3 - const val QUEUE_CHANNEL = 4 - const val QUEUE_YT_SINGLE = 5 - const val QUEUE_YT_SEARCH = 6 - const val QUEUE_YT_PLAYLIST = 7 - const val QUEUE_YT_CHANNEL = 8 - - @IntDef(TYPE_SQUARE, TYPE_RECTANGLE) - @Retention(AnnotationRetention.SOURCE) - annotation class ArtworkType - - const val TYPE_SQUARE = 0 - const val TYPE_RECTANGLE = 1 + const val EXTRA_BLOCK = "block" const val STATE_NOT_DOWNLOADED = 0 const val STATE_PREPARING = 1 diff --git a/app/src/main/java/com/zionhuang/music/constants/SongSortType.kt b/app/src/main/java/com/zionhuang/music/constants/SongSortType.kt deleted file mode 100644 index c15bffcb0..000000000 --- a/app/src/main/java/com/zionhuang/music/constants/SongSortType.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zionhuang.music.constants - -import androidx.annotation.IntDef - -@IntDef(ORDER_CREATE_DATE, ORDER_ARTIST, ORDER_NAME) -@Retention(AnnotationRetention.SOURCE) -annotation class SongSortType - -const val ORDER_CREATE_DATE = 0 -const val ORDER_NAME = 1 -const val ORDER_ARTIST = 2 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 bf51bfb88..ad9470910 100644 --- a/app/src/main/java/com/zionhuang/music/db/Converters.kt +++ b/app/src/main/java/com/zionhuang/music/db/Converters.kt @@ -1,14 +1,16 @@ package com.zionhuang.music.db import androidx.room.TypeConverter -import java.util.* +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset class Converters { @TypeConverter - fun fromTimestamp(value: Long?): Date? { - return value?.let { Date(it) } - } + fun fromTimestamp(value: Long): LocalDateTime = + LocalDateTime.ofInstant(Instant.ofEpochMilli(value), ZoneOffset.UTC) @TypeConverter - fun dateToTimestamp(date: Date?): Long? = date?.time + fun dateToTimestamp(date: LocalDateTime): Long = + date.atZone(ZoneOffset.UTC).toInstant().toEpochMilli() } \ No newline at end of file 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 bca172b13..94cc701c2 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -1,29 +1,51 @@ package com.zionhuang.music.db import android.content.Context +import android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT +import androidx.core.content.contentValuesOf import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters -import com.zionhuang.music.db.daos.ArtistDao -import com.zionhuang.music.db.daos.DownloadDao -import com.zionhuang.music.db.daos.PlaylistDao -import com.zionhuang.music.db.daos.SongDao +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.zionhuang.music.db.daos.* 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.ZoneOffset +import java.util.* -@Database(entities = [ - SongEntity::class, - ArtistEntity::class, - PlaylistEntity::class, - PlaylistSongEntity::class, - DownloadEntity::class -], version = 1, exportSchema = false) +@Database( + entities = [ + SongEntity::class, + ArtistEntity::class, + AlbumEntity::class, + PlaylistEntity::class, + SongArtistMap::class, + SongAlbumMap::class, + AlbumArtistMap::class, + PlaylistSongMap::class, + DownloadEntity::class, + SearchHistory::class + ], + views = [ + SortedSongArtistMap::class, + PlaylistSongMapPreview::class + ], + version = 2, + exportSchema = true +) @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 companion object { private const val DBNAME = "song.db" @@ -35,11 +57,153 @@ abstract class MusicDatabase : RoomDatabase() { if (INSTANCE == null) { synchronized(MusicDatabase::class.java) { if (INSTANCE == null) { - INSTANCE = Room.databaseBuilder(context, MusicDatabase::class.java, DBNAME).build() + INSTANCE = Room.databaseBuilder(context, MusicDatabase::class.java, DBNAME) + .addMigrations(MIGRATION_1_2) + .build() } } } return INSTANCE!! } } +} + +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + 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() + artistMap[oldId] = newId + artists.add(ArtistEntity( + id = newId, + name = cursor.getString(1) + )) + } + } + + val playlistMap = mutableMapOf() + val playlists = mutableListOf() + database.query("SELECT * FROM playlist".toSQLiteQuery()).use { cursor -> + while (cursor.moveToNext()) { + val oldId = cursor.getInt(0) + val newId = generatePlaylistId() + playlistMap[oldId] = newId + 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) + )) + } + } + // ensure we have continuous playlist song position + playlistSongMaps.sortBy { it.position } + val playlistSongCount = mutableMapOf() + playlistSongMaps.map { map -> + if (map.playlistId !in playlistSongCount) playlistSongCount[map.playlistId] = 0 + map.copy(position = playlistSongCount[map.playlistId]!!).also { + playlistSongCount[map.playlistId] = playlistSongCount[map.playlistId]!! + 1 + } + } + 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 + )) + } + } + database.execSQL("DROP TABLE IF EXISTS song") + database.execSQL("DROP TABLE IF EXISTS artist") + database.execSQL("DROP TABLE IF EXISTS playlist") + database.execSQL("DROP TABLE IF EXISTS playlist_song") + database.execSQL("CREATE TABLE IF NOT EXISTS `song` (`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`))") + database.execSQL("CREATE TABLE IF NOT EXISTS `artist` (`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`))") + database.execSQL("CREATE TABLE IF NOT EXISTS `album` (`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`))") + database.execSQL("CREATE TABLE IF NOT EXISTS `playlist` (`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`))") + database.execSQL("CREATE TABLE IF NOT EXISTS `song_artist_map` (`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 )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `song_artist_map` (`songId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `song_artist_map` (`artistId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `song_album_map` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER, 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 )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `song_album_map` (`songId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `song_album_map` (`albumId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `album_artist_map` (`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 )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `album_artist_map` (`albumId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `album_artist_map` (`artistId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `playlist_song_map` (`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 )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `playlist_song_map` (`playlistId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `playlist_song_map` (`songId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `download` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))") + database.execSQL("CREATE TABLE IF NOT EXISTS `search_history` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `search_history` (`query`)") + 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) + )) + } + 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) + )) + } + songArtistMaps.forEach { songArtistMap -> + database.insert("song_artist_map", 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) + )) + } + playlistSongMaps.forEach { playlistSongMap -> + database.insert("playlist_song_map", CONFLICT_ABORT, contentValuesOf( + "playlistId" to playlistSongMap.playlistId, + "songId" to playlistSongMap.songId, + "position" to playlistSongMap.position + )) + } + } } \ 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 new file mode 100644 index 000000000..793db820a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt @@ -0,0 +1,74 @@ +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> + + @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 index b1e708f3f..399423f8f 100644 --- a/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt @@ -1,35 +1,79 @@ package com.zionhuang.music.db.daos -import androidx.paging.PagingSource 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 { - @Query("SELECT * FROM artist") - suspend fun getAllArtistsAsList(): List + @Transaction + @RawQuery(observedEntities = [ArtistEntity::class, SongArtistMap::class]) + fun getArtistsAsFlow(query: SupportSQLiteQuery): Flow> - @Query("SELECT * FROM artist") - fun getAllArtistsAsPagingSource(): PagingSource + 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: Int): ArtistEntity? + suspend fun getArtistById(id: String): ArtistEntity? @Query("SELECT * FROM artist WHERE name = :name") suspend fun getArtistByName(name: String): ArtistEntity? - @Query("SELECT id FROM artist WHERE name = :name") - suspend fun getArtistId(name: String): Int? + @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 * FROM artist WHERE name LIKE '%' || :query || '%'") - suspend fun searchArtists(query: String): List + @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(artist: ArtistEntity) + 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 index 8052c6992..33f542433 100644 --- a/app/src/main/java/com/zionhuang/music/db/daos/DownloadDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/daos/DownloadDao.kt @@ -1,6 +1,5 @@ package com.zionhuang.music.db.daos -import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Insert import androidx.room.Query @@ -8,9 +7,6 @@ import com.zionhuang.music.db.entities.DownloadEntity @Dao interface DownloadDao { - @Query("SELECT * FROM download") - fun getAllDownloadEntitiesAsLiveData(): LiveData> - @Query("SELECT * FROM download WHERE id = :downloadId") suspend fun getDownloadEntity(downloadId: Long): DownloadEntity? 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 index 9a7260233..4211c7485 100644 --- a/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt @@ -1,47 +1,105 @@ package com.zionhuang.music.db.daos -import androidx.paging.PagingSource import androidx.room.* -import com.zionhuang.music.db.entities.ArtistEntity +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.PlaylistSongEntity +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 { - @Query("SELECT * FROM playlist") - suspend fun getAllPlaylistsAsList(): List + @Transaction + @Query(QUERY_ALL_PLAYLIST) + suspend fun getAllPlaylistsAsList(): List - @Query("SELECT * FROM playlist") - fun getAllPlaylistsAsPagingSource(): PagingSource + @Transaction + @RawQuery(observedEntities = [PlaylistEntity::class, PlaylistSongMap::class]) + fun getPlaylistsAsFlow(query: SupportSQLiteQuery): Flow> - @Query("SELECT * FROM playlist WHERE playlistId = :id") - suspend fun getPlaylist(id: Int): PlaylistEntity? + fun getAllPlaylistsAsFlow(sortInfo: ISortInfo): Flow> = getPlaylistsAsFlow((QUERY_ALL_PLAYLIST + getSortQuery(sortInfo)).toSQLiteQuery()) - @Query("SELECT * FROM playlist WHERE name LIKE '%' || :query || '%'") - suspend fun searchPlaylists(query: String): List + @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 insertPlaylist(playlist: PlaylistEntity) + suspend fun insert(playlistSongMaps: List) + + @Update + suspend fun update(playlist: PlaylistEntity) @Update - suspend fun updatePlaylist(playlist: PlaylistEntity) + suspend fun update(playlistSongMap: PlaylistSongMap) + + @Update + suspend fun update(playlistSongMaps: List) @Delete - suspend fun deletePlaylist(playlist: PlaylistEntity) + suspend fun delete(playlists: List) - @Query("SELECT * FROM playlist_song WHERE playlistId = :playlistId ORDER BY idInPlaylist") - suspend fun getPlaylistSongEntities(playlistId: Int): List + suspend fun deletePlaylistSong(playlistId: String, position: Int) = deletePlaylistSong(playlistId, listOf(position)) - @Update - suspend fun updatePlaylistSongEntities(list: List) + @Query("DELETE FROM playlist_song_map WHERE playlistId = :playlistId AND position IN (:position)") + suspend fun deletePlaylistSong(playlistId: String, position: List) - @Insert - suspend fun insertPlaylistSongEntities(playlistSong: List) + @Query("SELECT max(position) FROM playlist_song_map WHERE playlistId = :playlistId") + suspend fun getPlaylistMaxId(playlistId: String): Int? - @Query("DELETE FROM playlist_song WHERE playlistId = :playlistId AND idInPlaylist = (:idInPlaylist)") - suspend fun deletePlaylistSongEntities(playlistId: Int, idInPlaylist: List) + 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" + ) - @Query("SELECT max(idInPlaylist) FROM playlist_song WHERE playlistId = :playlistId") - suspend fun getPlaylistMaxId(playlistId: Int): Int? + 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 new file mode 100644 index 000000000..6644b95c7 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt @@ -0,0 +1,22 @@ +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) +} \ 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 index 8209900ad..6a8dfb6b7 100644 --- a/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt @@ -1,76 +1,95 @@ package com.zionhuang.music.db.daos import androidx.lifecycle.LiveData -import androidx.paging.PagingSource import androidx.room.* import androidx.sqlite.db.SupportSQLiteQuery -import com.zionhuang.music.constants.ORDER_ARTIST -import com.zionhuang.music.constants.ORDER_CREATE_DATE -import com.zionhuang.music.constants.ORDER_NAME -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.db.entities.* import com.zionhuang.music.extensions.toSQLiteQuery -import com.zionhuang.music.models.base.ISortInfo +import com.zionhuang.music.models.sortInfo.ISortInfo +import com.zionhuang.music.models.sortInfo.SongSortType +import kotlinx.coroutines.flow.Flow @Dao interface SongDao { - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) @Transaction - @Query("SELECT * FROM song WHERE id = :songId") - suspend fun getSong(songId: String): Song? + @RawQuery(observedEntities = [SongEntity::class, ArtistEntity::class, AlbumEntity::class, SongArtistMap::class, SongAlbumMap::class]) + suspend fun getSongsAsList(query: SupportSQLiteQuery): List - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) @Transaction - @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND NOT isTrash") - fun searchSongsAsPagingSource(query: String): PagingSource + @RawQuery(observedEntities = [SongEntity::class, ArtistEntity::class, AlbumEntity::class, SongArtistMap::class, SongAlbumMap::class]) + fun getSongsAsFlow(query: SupportSQLiteQuery): Flow> - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(songs: List) + suspend fun getAllSongsAsList(sortInfo: ISortInfo): List = getSongsAsList((QUERY_ALL_SONG + getSortQuery(sortInfo)).toSQLiteQuery()) + fun getAllSongsAsFlow(sortInfo: ISortInfo): Flow> = getSongsAsFlow((QUERY_ALL_SONG + getSortQuery(sortInfo)).toSQLiteQuery()) - @Update - suspend fun update(songs: List) + @Query("SELECT COUNT(*) FROM song WHERE NOT isTrash") + suspend fun getSongCount(): Int - @Query("SELECT EXISTS (SELECT 1 FROM song WHERE id=:songId)") - suspend fun hasSong(songId: String): Boolean + 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 EXISTS (SELECT 1 FROM song WHERE id=:songId)") - fun hasSongLiveData(songId: String): LiveData + @Query("SELECT COUNT(*) FROM song_artist_map WHERE artistId = :artistId") + suspend fun getArtistSongCount(artistId: String): Int - @Query("DELETE FROM song WHERE id IN (:songIds)") - suspend fun delete(songIds: List) + @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 - @RawQuery - suspend fun getSongsAsList(query: SupportSQLiteQuery): List + @Query(QUERY_PLAYLIST_SONGS) + fun getPlaylistSongsAsList(playlistId: String): List @Transaction - @RawQuery(observedEntities = [SongEntity::class, ArtistEntity::class]) - fun getSongsAsPagingSource(query: SupportSQLiteQuery): PagingSource + @Query(QUERY_PLAYLIST_SONGS) + fun getPlaylistSongsAsFlow(playlistId: String): Flow> - suspend fun getAllSongsAsList(sortInfo: ISortInfo): List = getSongsAsList((QUERY_ALL_SONG + getSortQuery(sortInfo)).toSQLiteQuery()) - fun getAllSongsAsPagingSource(sortInfo: ISortInfo): PagingSource = getSongsAsPagingSource((QUERY_ALL_SONG + getSortQuery(sortInfo)).toSQLiteQuery()) - suspend fun getArtistSongsAsList(artistId: Int, sortInfo: ISortInfo): List = getSongsAsList((QUERY_ARTIST_SONG.format(artistId) + getSortQuery(sortInfo)).toSQLiteQuery()) - fun getArtistSongsAsPagingSource(artistId: Int, sortInfo: ISortInfo): PagingSource = getSongsAsPagingSource((QUERY_ARTIST_SONG.format(artistId) + getSortQuery(sortInfo)).toSQLiteQuery()) + @Transaction + @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) + @Query("SELECT * FROM song WHERE id = :songId") + suspend fun getSong(songId: String): Song? @Transaction - @Query(QUERY_PLAYLIST_SONGS) - fun getPlaylistSongsAsPagingSource(playlistId: Int): PagingSource + @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) + @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND NOT isTrash") + fun searchSongs(query: String): Flow> @Transaction - @Query(QUERY_PLAYLIST_SONGS) - suspend fun getPlaylistSongsAsList(playlistId: Int): List + @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) - @Query("SELECT COUNT(id) FROM song WHERE artistId = :artistId") - suspend fun artistSongsCount(artistId: Int): Int + @Update + suspend fun update(song: SongEntity) - fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format( + @Update + suspend fun update(songs: List) + + @Delete + suspend fun delete(songs: List) + + fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format( when (sortInfo.type) { - ORDER_CREATE_DATE -> "create_date" - ORDER_NAME -> "title" - ORDER_ARTIST -> "artistId" + SongSortType.CREATE_DATE -> "song.create_date" + SongSortType.NAME -> "song.title" else -> throw IllegalArgumentException("Unexpected song sort type.") }, if (sortInfo.isDescending) "DESC" else "ASC" @@ -78,16 +97,23 @@ interface SongDao { companion object { private const val QUERY_ALL_SONG = "SELECT * FROM song WHERE NOT isTrash" - private const val QUERY_ARTIST_SONG = "SELECT * FROM song WHERE artistId = %d AND 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_ORDER = " ORDER BY %s %s" private const val QUERY_PLAYLIST_SONGS = """ - SELECT song.*, playlist_song.idInPlaylist - FROM playlist_song + SELECT song.*, playlist_song_map.position + FROM playlist_song_map JOIN song - ON playlist_song.songId = song.id + ON playlist_song_map.songId = song.id WHERE playlistId = :playlistId AND NOT song.isTrash - ORDER BY playlist_song.idInPlaylist + ORDER BY playlist_song_map.position """ } } \ 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 new file mode 100644 index 000000000..46e2fbc46 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/Album.kt @@ -0,0 +1,24 @@ +package com.zionhuang.music.db.entities + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +data class Album( + @Embedded + val album: AlbumEntity, + @Relation( + entity = ArtistEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = Junction( + value = AlbumArtistMap::class, + parentColumn = "albumId", + entityColumn = "artistId" + ) + ) + val artists: List, +) : LocalItem() { + override val id: String + get() = album.id +} diff --git a/app/src/main/java/com/zionhuang/music/db/entities/AlbumArtistMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/AlbumArtistMap.kt new file mode 100644 index 000000000..5002d3d8e --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/AlbumArtistMap.kt @@ -0,0 +1,28 @@ +package com.zionhuang.music.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "album_artist_map", + primaryKeys = ["albumId", "artistId"], + foreignKeys = [ + ForeignKey( + entity = AlbumEntity::class, + parentColumns = ["id"], + childColumns = ["albumId"], + onDelete = ForeignKey.CASCADE), + ForeignKey( + entity = ArtistEntity::class, + parentColumns = ["id"], + childColumns = ["artistId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class AlbumArtistMap( + @ColumnInfo(index = true) val albumId: String, + @ColumnInfo(index = true) val artistId: String, + val order: Int, +) 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 new file mode 100644 index 000000000..f37cbb9fd --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt @@ -0,0 +1,20 @@ +package com.zionhuang.music.db.entities + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize +import java.time.LocalDateTime + +@Parcelize +@Entity(tableName = "album") +data class AlbumEntity( + @PrimaryKey val id: String, + val title: String, + val year: Int? = null, + val thumbnailUrl: String? = null, + val songCount: Int, + val duration: Int, + val createDate: LocalDateTime = LocalDateTime.now(), + val lastUpdateTime: LocalDateTime = LocalDateTime.now(), +) : Parcelable \ No newline at end of file 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 new file mode 100644 index 000000000..742d92e08 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt @@ -0,0 +1,12 @@ +package com.zionhuang.music.db.entities + +import androidx.room.Embedded + +data class Artist( + @Embedded + val artist: ArtistEntity, + val songCount: Int, +) : LocalItem() { + override val id: String + get() = artist.id +} 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 5e1bca5f9..95a4e8dc4 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 @@ -2,16 +2,31 @@ package com.zionhuang.music.db.entities import android.os.Parcelable import androidx.room.Entity -import androidx.room.Index import androidx.room.PrimaryKey import kotlinx.parcelize.Parcelize +import org.apache.commons.lang3.RandomStringUtils +import java.time.LocalDateTime @Parcelize -@Entity(tableName = "artist", - indices = [Index(value = ["id"], unique = true)]) +@Entity(tableName = "artist") data class ArtistEntity( - @PrimaryKey(autoGenerate = true) val id: Int? = null, - val name: String, + @PrimaryKey val id: String, + val name: String, + val thumbnailUrl: String? = null, + val bannerUrl: String? = null, + val description: String? = null, + val createDate: LocalDateTime = LocalDateTime.now(), + val lastUpdateTime: LocalDateTime = LocalDateTime.now(), ) : Parcelable { override fun toString(): String = name + + val isYouTubeArtist: Boolean + get() = id.startsWith("UC") + + val isLocalArtist: Boolean + get() = id.startsWith("LA") + + companion object { + fun generateArtistId() = "LA" + RandomStringUtils.random(8, true, false) + } } 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 index 60d672607..7a0977053 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/DownloadEntity.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/DownloadEntity.kt @@ -5,7 +5,6 @@ import androidx.room.PrimaryKey @Entity(tableName = "download") data class DownloadEntity( - @PrimaryKey - val id: Long, + @PrimaryKey val id: Long, val songId: String, ) 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 new file mode 100644 index 000000000..5a4c75d5b --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/LocalItem.kt @@ -0,0 +1,56 @@ +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.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 { + 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 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 new file mode 100644 index 000000000..f80acad05 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt @@ -0,0 +1,26 @@ +package com.zionhuang.music.db.entities + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +data class Playlist( + @Embedded + val playlist: PlaylistEntity, + val songCount: Int, + @Relation( + entity = SongEntity::class, + entityColumn = "id", + parentColumn = "id", + projection = ["thumbnailUrl"], + associateBy = Junction( + value = PlaylistSongMapPreview::class, + parentColumn = "playlistId", + entityColumn = "songId" + ) + ) + val thumbnails: List, +) : LocalItem() { + override val id: String + get() = playlist.id +} 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 6cda39bc2..132845b91 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 @@ -2,16 +2,30 @@ package com.zionhuang.music.db.entities import android.os.Parcelable import androidx.room.Entity -import androidx.room.Index import androidx.room.PrimaryKey import kotlinx.parcelize.Parcelize +import org.apache.commons.lang3.RandomStringUtils +import java.time.LocalDateTime @Parcelize -@Entity( - tableName = "playlist", - indices = [Index(value = ["playlistId"], unique = true)] -) +@Entity(tableName = "playlist") data class PlaylistEntity( - @PrimaryKey(autoGenerate = true) val playlistId: Int = 0, + @PrimaryKey val id: String, val name: String, -) : Parcelable + 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 + + companion object { + 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 deleted file mode 100644 index 2a134356f..000000000 --- a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSong.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.zionhuang.music.db.entities - -import androidx.room.Junction -import androidx.room.Relation - -data class PlaylistSong( - val playlistId: Int, - @Relation(entity = SongEntity::class, - parentColumn = "playlistId", - entityColumn = "songId", - associateBy = Junction(PlaylistSongEntity::class)) - val song: Song, -) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSongEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSongEntity.kt deleted file mode 100644 index 2479fd95f..000000000 --- a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSongEntity.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.zionhuang.music.db.entities - -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import androidx.room.PrimaryKey - -@Entity(tableName = "playlist_song", - indices = [Index("playlistId"), Index("songId")], - foreignKeys = [ - ForeignKey( - entity = PlaylistEntity::class, - parentColumns = ["playlistId"], - childColumns = ["playlistId"], - onDelete = ForeignKey.CASCADE - ), - ForeignKey( - entity = SongEntity::class, - parentColumns = ["id"], - childColumns = ["songId"], - onDelete = ForeignKey.CASCADE)]) -data class PlaylistSongEntity( - @PrimaryKey(autoGenerate = true) val id: Int = 0, - val playlistId: Int, - val songId: String, - val idInPlaylist: Int = 0, -) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSongMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSongMap.kt new file mode 100644 index 000000000..6c85393b9 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSongMap.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 = "playlist_song_map", + foreignKeys = [ + ForeignKey( + entity = PlaylistEntity::class, + parentColumns = ["id"], + childColumns = ["playlistId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE) + ] +) +data class PlaylistSongMap( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + @ColumnInfo(index = true) val playlistId: String, + @ColumnInfo(index = true) val songId: String, + val position: Int = 0, +) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSongMapPreview.kt b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSongMapPreview.kt new file mode 100644 index 000000000..85b2290f7 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSongMapPreview.kt @@ -0,0 +1,13 @@ +package com.zionhuang.music.db.entities + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@DatabaseView( + viewName = "playlist_song_map_preview", + value = "SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position") +data class PlaylistSongMapPreview( + @ColumnInfo(index = true) val playlistId: String, + @ColumnInfo(index = true) val songId: String, + val idInPlaylist: Int = 0, +) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/SearchHistory.kt b/app/src/main/java/com/zionhuang/music/db/entities/SearchHistory.kt new file mode 100644 index 000000000..29ba0b374 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/SearchHistory.kt @@ -0,0 +1,17 @@ +package com.zionhuang.music.db.entities + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "search_history", + indices = [Index( + value = ["query"], + unique = true + )] +) +data class SearchHistory( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val query: 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 6603fa497..05d403339 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,28 +1,38 @@ package com.zionhuang.music.db.entities import android.os.Parcelable -import androidx.room.ColumnInfo -import androidx.room.Ignore +import androidx.room.Embedded +import androidx.room.Junction import androidx.room.Relation -import com.zionhuang.music.constants.MediaConstants.ArtworkType -import com.zionhuang.music.constants.MediaConstants.STATE_NOT_DOWNLOADED -import com.zionhuang.music.constants.MediaConstants.TYPE_RECTANGLE import kotlinx.parcelize.Parcelize -import java.util.* @Parcelize data class Song @JvmOverloads constructor( - val id: String, - val title: String, - private val artistId: Int = -1, - @Relation(entity = ArtistEntity::class, parentColumn = "artistId", entityColumn = "id", projection = ["name"]) - val artistName: String = "", - @ArtworkType val artworkType: Int = TYPE_RECTANGLE, - val duration: Int = -1, // in seconds - val liked: Boolean = false, - val isTrash: Boolean = false, - @ColumnInfo(name = "download_state") val downloadState: Int = STATE_NOT_DOWNLOADED, - @ColumnInfo(name = "create_date") val createDate: Date = Date(), - @ColumnInfo(name = "modify_date") val modifyDate: Date = Date(), - val idInPlaylist: Int? = -1, -) : Parcelable \ No newline at end of file + @Embedded val song: SongEntity, + @Relation( + entity = ArtistEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = Junction( + value = SortedSongArtistMap::class, + parentColumn = "songId", + entityColumn = "artistId" + ) + ) + val artists: List, + @Relation( + entity = AlbumEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = Junction( + value = SongAlbumMap::class, + parentColumn = "songId", + entityColumn = "albumId" + ) + ) + val album: AlbumEntity? = null, + val position: Int? = -1, +) : LocalItem(), Parcelable { + 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 new file mode 100644 index 000000000..6a68a3e4b --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt @@ -0,0 +1,28 @@ +package com.zionhuang.music.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "song_album_map", + primaryKeys = ["songId", "albumId"], + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE), + ForeignKey( + entity = AlbumEntity::class, + parentColumns = ["id"], + childColumns = ["albumId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class SongAlbumMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val albumId: String, + val index: Int? = null, +) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/SongArtistMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/SongArtistMap.kt new file mode 100644 index 000000000..053bd4adc --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/SongArtistMap.kt @@ -0,0 +1,28 @@ +package com.zionhuang.music.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "song_artist_map", + primaryKeys = ["songId", "artistId"], + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE), + ForeignKey( + entity = ArtistEntity::class, + parentColumns = ["id"], + childColumns = ["artistId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class SongArtistMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val artistId: String, + val position: 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 27e8f8443..ace58a1b2 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,37 +1,29 @@ package com.zionhuang.music.db.entities -import androidx.room.* -import com.zionhuang.music.constants.MediaConstants.ArtworkType +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey import com.zionhuang.music.constants.MediaConstants.STATE_NOT_DOWNLOADED -import java.util.* +import kotlinx.parcelize.Parcelize +import java.time.LocalDateTime -@Entity( - tableName = "song", - indices = [ - Index(value = ["id"], unique = true), - Index(value = ["artistId"])], - foreignKeys = [ - ForeignKey( - entity = ArtistEntity::class, - parentColumns = ["id"], - childColumns = ["artistId"], - onDelete = ForeignKey.CASCADE - )] -) +@Parcelize +@Entity(tableName = "song") data class SongEntity( - @PrimaryKey - val id: String, + @PrimaryKey val id: String, val title: String, - val artistId: Int = 0, val duration: Int = 0, // in seconds + val thumbnailUrl: String? = null, + val albumId: String? = null, + val albumName: String? = null, val liked: Boolean = false, - @ArtworkType - val artworkType: Int, + 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: Date = Date(), + val createDate: LocalDateTime = LocalDateTime.now(), @ColumnInfo(name = "modify_date") - val modifyDate: Date = Date(), -) + val modifyDate: LocalDateTime = LocalDateTime.now(), +) : Parcelable diff --git a/app/src/main/java/com/zionhuang/music/db/entities/SortedSongArtistMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/SortedSongArtistMap.kt new file mode 100644 index 000000000..77ab4a57d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/SortedSongArtistMap.kt @@ -0,0 +1,13 @@ +package com.zionhuang.music.db.entities + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@DatabaseView( + viewName = "sorted_song_artist_map", + value = "SELECT * FROM song_artist_map ORDER BY position") +data class SortedSongArtistMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val artistId: String, + val position: Int, +) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/StreamEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/StreamEntity.kt deleted file mode 100644 index 528863c05..000000000 --- a/app/src/main/java/com/zionhuang/music/db/entities/StreamEntity.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zionhuang.music.db.entities - -import androidx.room.Entity - -@Entity(tableName = "stream") -data class StreamEntity( - val id: String, - val mimeType: String, - val ext: String, - val abr: Int, -) diff --git a/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt b/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt index 7d892a734..62ecfb6b3 100644 --- a/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt +++ b/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt @@ -6,15 +6,15 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import androidx.core.content.getSystemService -import com.zionhuang.music.constants.MediaConstants.STATE_DOWNLOADED -import com.zionhuang.music.constants.MediaConstants.STATE_NOT_DOWNLOADED 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 @@ -24,12 +24,9 @@ class DownloadBroadcastReceiver : BroadcastReceiver() { val id = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1) if (id == -1L) return downloadManager.query(Query().setFilterById(id)).use { cursor -> - val isSuccess = cursor.moveToFirst() && cursor.get(COLUMN_STATUS) == STATUS_SUCCESSFUL + val success = cursor.moveToFirst() && cursor.get(COLUMN_STATUS) == STATUS_SUCCESSFUL GlobalScope.launch(IO) { - val songId = songRepository.getDownloadEntity(id)?.songId ?: return@launch - val song = songRepository.getSongById(songId) ?: return@launch - songRepository.updateSong(song.copy(downloadState = if (isSuccess) STATE_DOWNLOADED else STATE_NOT_DOWNLOADED)) - songRepository.removeDownloadEntity(id) + songRepository.onDownloadComplete(id, success) } } } diff --git a/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt b/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt index 423597029..65f5d0d2e 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt @@ -1,4 +1,10 @@ package com.zionhuang.music.extensions +import android.util.Log + val Any.TAG: String - get() = javaClass.simpleName \ No newline at end of file + 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/ContextExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt index 964d0b272..f89c97fc4 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt @@ -15,7 +15,6 @@ import androidx.preference.PreferenceManager import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import com.zionhuang.music.utils.preference.Preference import com.zionhuang.music.utils.preference.PreferenceLiveData -import com.zionhuang.music.utils.preference.serializablePreference fun Context.getDensity(): Float = resources.displayMetrics.density @@ -41,6 +40,5 @@ val Context.sharedPreferences: SharedPreferences get() = PreferenceManager.getDefaultSharedPreferences(this) fun Context.preference(@StringRes keyId: Int, defaultValue: T) = Preference(this, keyId, defaultValue) -inline fun Context.serializablePreference(keyId: Int, defaultValue: T): Preference = serializablePreference(this, keyId, defaultValue) fun Context.preferenceLiveData(@StringRes keyId: Int, defaultValue: T) = PreferenceLiveData(this, keyId, defaultValue) diff --git a/app/src/main/java/com/zionhuang/music/extensions/EditTextExt.kt b/app/src/main/java/com/zionhuang/music/extensions/EditTextExt.kt new file mode 100644 index 000000000..a653900e6 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/extensions/EditTextExt.kt @@ -0,0 +1,14 @@ +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/GlideExt.kt b/app/src/main/java/com/zionhuang/music/extensions/GlideExt.kt deleted file mode 100644 index 1b2179d96..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/GlideExt.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.zionhuang.music.extensions - -import com.bumptech.glide.load.MultiTransformation -import com.bumptech.glide.load.resource.bitmap.CenterCrop -import com.bumptech.glide.load.resource.bitmap.CircleCrop -import com.bumptech.glide.load.resource.bitmap.RoundedCorners -import com.bumptech.glide.request.RequestOptions -import com.bumptech.glide.request.target.Target -import com.zionhuang.music.utils.GlideRequest -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.runBlocking - -fun GlideRequest.getBlocking(): R? = - try { - runBlocking(IO) { - submit().get() - } - } catch (e: Exception) { - null - } - -fun GlideRequest<*>.circle() = apply(RequestOptions.bitmapTransform(CircleCrop())) - -fun GlideRequest<*>.fullResolution() = override(Target.SIZE_ORIGINAL) - -fun GlideRequest<*>.roundCorner(px: Int) = transform(MultiTransformation(CenterCrop(), RoundedCorners(px))) diff --git a/app/src/main/java/com/zionhuang/music/extensions/ImageViewExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ImageViewExt.kt deleted file mode 100644 index 44d6088ca..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/ImageViewExt.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.zionhuang.music.extensions - -import android.net.Uri -import android.widget.ImageView -import com.zionhuang.music.utils.GlideApp -import com.zionhuang.music.utils.GlideRequest -import java.io.File - -fun ImageView.load(url: String?, applier: (GlideRequest<*>.() -> Unit)? = null) { - GlideApp.with(this) - .load(url) - .apply { applier?.invoke(this) } - .into(this) -} - -fun ImageView.load(file: File?, applier: (GlideRequest<*>.() -> Unit)? = null) { - GlideApp.with(this) - .load(file) - .apply { applier?.invoke(this) } - .into(this) -} - -fun ImageView.load(uri: Uri?, applier: (GlideRequest<*>.() -> Unit)? = null) { - GlideApp.with(this) - .load(uri) - .apply { applier?.invoke(this) } - .into(this) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/JsonElementExt.kt b/app/src/main/java/com/zionhuang/music/extensions/JsonElementExt.kt deleted file mode 100644 index 3afb39a3d..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/JsonElementExt.kt +++ /dev/null @@ -1,203 +0,0 @@ -package com.zionhuang.music.extensions - -import com.google.gson.* -import java.io.Reader - -fun Reader.parseJson(): JsonElement = JsonParser.parseReader(this) - -fun Any.toJsonElement(): JsonElement = when (this) { - is JsonElement -> this - is Boolean -> JsonPrimitive(this) - is Number -> JsonPrimitive(this) - is String -> JsonPrimitive(this) - is Char -> JsonPrimitive(this) - else -> throw IllegalArgumentException("${this.javaClass.name} cannot be converted to JSON") -} - -/** - * JsonObject - */ -fun jsonObjectOf(vararg pairs: Pair): JsonObject { - val obj = JsonObject() - for ((key, value) in pairs) { - if (value == null) continue - when (value) { - is JsonElement -> obj.add(key, value) - is Boolean -> obj.addProperty(key, value) - is Number -> obj.addProperty(key, value) - is String -> obj.addProperty(key, value) - is Char -> obj.addProperty(key, value) - else -> throw IllegalArgumentException("${value.javaClass.name} cannot be converted to JSON") - } - } - return obj -} - -operator fun JsonElement?.get(key: String): JsonElement? = if (this is JsonObject) this.get(key) else null -operator fun JsonObject?.get(key: String): JsonElement? = this?.get(key) -operator fun JsonObject?.set(key: String, value: JsonElement) = this?.add(key, value) -operator fun JsonObject?.contains(key: String) = this?.has(key) ?: false -fun Iterable>.toJsonObject(): JsonObject = JsonObject().apply { - for (pair in this@toJsonObject) { - this[pair.first] = pair.second.toJsonElement() - } -} - -/** - * JsonArray - */ -fun jsonArrayOf(vararg items: Any) = JsonArray().apply { - for (item in items) { - add(item.toJsonElement()) - } -} - -fun Array.toJsonArray(): JsonArray = JsonArray().apply { - for (item in this@toJsonArray) { - add(item.toJsonElement()) - } -} - -fun Iterable.toJsonArray(): JsonArray = JsonArray().apply { - for (item in this@toJsonArray) { - add(item.toJsonElement()) - } -} - -fun CharSequence.splitToJsonArray() = JsonArray().apply { - for (c in this@splitToJsonArray) { - add(c) - } -} - -fun Iterable.mapToJsonArray(transform: (T) -> JsonElement): JsonArray = JsonArray().apply { - for (item in this@mapToJsonArray) { - add(transform(item)) - } -} - -operator fun JsonElement?.get(index: Int): JsonElement? = if (this is JsonArray) this.get(index) else null -operator fun JsonArray?.get(index: Int): JsonElement? = this?.get(index) - -fun JsonArray.isNotEmpty() = this.size() > 0 - -fun JsonElement?.last(): JsonElement? = if (this is JsonArray && this.isNotEmpty()) this[size() - 1] else null - -fun JsonArray.selfReverse() = this.apply { - for (i in 0 until (size() shr 1)) { - val temp = this[i] - this[i] = this.get(size() - i - 1) - this[size() - i - 1] = temp - } -} - -/** - * JsonElement Type Extensions - */ -fun JsonElement.isString(): Boolean = this is JsonPrimitive && this.isString -fun JsonElement.isNumber(): Boolean = this is JsonPrimitive && this.isNumber - -val JsonElement?.asJsonArrayOrNull: JsonArray? - get() = if (this is JsonArray) this else null - -val JsonElement?.asJsonObjectOrNull: JsonObject? - get() = if (this is JsonObject) this else null - -val JsonElement?.asBooleanOrNull: Boolean? - get() = try { - this?.asBoolean - } catch (e: Exception) { - null - } - -val JsonElement?.asStringOrNull: String? - get() = try { - this?.asString - } catch (e: Exception) { - null - } -val JsonElement?.asStringOrBlank: String - get() = this?.asStringOrNull ?: "" - -val JsonElement?.asNumberOrNull: Number? - get() = try { - this?.asNumber - } catch (e: Exception) { - null - } -val JsonElement?.asIntOrNull: Int? - get() = this.asNumberOrNull?.toInt() -val JsonElement?.asFloatOrNull: Float? - get() = this.asNumberOrNull?.toFloat() - -fun Int.toJsonPrimitive(): JsonPrimitive = JsonPrimitive(this) -fun String.toJsonPrimitive(): JsonPrimitive = JsonPrimitive(this) - -/** - * JsonElement Shortcuts for "equals" methods - */ -fun JsonElement?.equals(value: Boolean) = this?.equals(JsonPrimitive(value)) ?: false -fun JsonElement?.equals(value: Number) = this?.equals(JsonPrimitive(value)) ?: false -fun JsonElement?.equals(value: String) = this?.equals(JsonPrimitive(value)) ?: false -fun JsonElement?.equals(value: Char) = this?.equals(JsonPrimitive(value)) ?: false - -infix fun JsonElement.or(rhs: JsonElement): JsonElement = - when { - this.isNumber() && rhs.isNumber() -> (this.asInt or rhs.asInt).toJsonPrimitive() - else -> JsonNull.INSTANCE - } - -infix fun JsonElement.xor(rhs: JsonElement): JsonElement = - when { - this.isNumber() && rhs.isNumber() -> (this.asInt xor rhs.asInt).toJsonPrimitive() - else -> JsonNull.INSTANCE - } - -infix fun JsonElement.and(rhs: JsonElement): JsonElement = - when { - this.isNumber() && rhs.isNumber() -> (this.asInt and rhs.asInt).toJsonPrimitive() - else -> JsonNull.INSTANCE - } - -infix fun JsonElement.shr(rhs: JsonElement): JsonElement = - when { - this.isNumber() && rhs.isNumber() -> (this.asInt shr rhs.asInt).toJsonPrimitive() - else -> JsonNull.INSTANCE - } - -infix fun JsonElement.shl(rhs: JsonElement): JsonElement = - when { - this.isNumber() && rhs.isNumber() -> (this.asInt shl rhs.asInt).toJsonPrimitive() - else -> JsonNull.INSTANCE - } - -operator fun JsonElement.minus(rhs: JsonElement): JsonElement = - when { - this.isNumber() && rhs.isNumber() -> (this.asInt - rhs.asInt).toJsonPrimitive() - else -> JsonNull.INSTANCE - } - -operator fun JsonElement.plus(rhs: JsonElement): JsonElement = - when { - this.isNumber() && rhs.isNumber() -> (this.asInt + rhs.asInt).toJsonPrimitive() - this.isString() && rhs.isString() -> (this.asString + rhs.asString).toJsonPrimitive() - else -> JsonNull.INSTANCE - } - -operator fun JsonElement.rem(rhs: JsonElement): JsonElement = - when { - this.isNumber() && rhs.isNumber() -> (this.asInt % rhs.asInt).toJsonPrimitive() - else -> JsonNull.INSTANCE - } - -operator fun JsonElement.div(rhs: JsonElement): JsonElement = - when { - this.isNumber() && rhs.isNumber() -> (this.asInt / rhs.asInt).toJsonPrimitive() - else -> JsonNull.INSTANCE - } - -operator fun JsonElement.times(rhs: JsonElement): JsonElement = - when { - this.isNumber() && rhs.isNumber() -> (this.asInt * rhs.asInt).toJsonPrimitive() - else -> JsonNull.INSTANCE - } \ 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 f06a293bd..2fb41d27c 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt @@ -3,6 +3,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 MutableList.swap(i: Int, j: Int) { +fun MutableList.swap(i: Int, j: Int): MutableList { this[i] = this[j].also { this[j] = this[i] } -} \ No newline at end of file + 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/MediaItemExt.kt b/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt index 9d434ae90..bdda724d8 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt @@ -1,34 +1,31 @@ package com.zionhuang.music.extensions -import android.content.Context -import android.support.v4.media.MediaDescriptionCompat import com.google.android.exoplayer2.MediaItem +import com.zionhuang.innertube.models.SongItem import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.models.MediaData -import com.zionhuang.music.models.toMediaData -import org.schabi.newpipe.extractor.InfoItem -import org.schabi.newpipe.extractor.stream.StreamInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem +import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.models.toMediaMetadata -val MediaItem.metadata: MediaData? - get() = localConfiguration?.tag as? MediaData +val MediaItem.metadata: MediaMetadata? + get() = localConfiguration?.tag as? MediaMetadata -private val mediaItemBuilder = MediaItem.Builder() +fun Song.toMediaItem() = MediaItem.Builder() + .setMediaId(song.id) + .setUri(song.id) + .setCustomCacheKey(song.id) + .setTag(toMediaMetadata()) + .build() -fun MediaData.toMediaItem() = mediaItemBuilder +fun SongItem.toMediaItem() = MediaItem.Builder() .setMediaId(id) - .setUri("music://$id") - .setTag(this) + .setUri(id) + .setCustomCacheKey(id) + .setTag(toMediaMetadata()) .build() -fun Song.toMediaItem(context: Context) = toMediaData(context).toMediaItem() - -fun StreamInfo.toMediaItem() = toMediaData().toMediaItem() - -fun StreamInfoItem.toMediaItem() = toMediaData().toMediaItem() - -fun MediaDescriptionCompat.toMediaItem() = toMediaData().toMediaItem() - -fun List.toMediaItems(context: Context): List = map { it.toMediaItem(context) } - -fun List.toMediaItems(): List = filterIsInstance().map { it.toMediaItem() } +fun MediaMetadata.toMediaItem() = MediaItem.Builder() + .setMediaId(id) + .setUri(id) + .setCustomCacheKey(id) + .setTag(this) + .build() \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/NanoJsonExt.kt b/app/src/main/java/com/zionhuang/music/extensions/NanoJsonExt.kt deleted file mode 100644 index 63e33b1ad..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/NanoJsonExt.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.zionhuang.music.extensions - -import com.grack.nanojson.JsonArray -import com.grack.nanojson.JsonObject - -fun JsonArray.getLastObject(): JsonObject = getObject(size - 1) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/NewPipeExt.kt b/app/src/main/java/com/zionhuang/music/extensions/NewPipeExt.kt deleted file mode 100644 index 92233c454..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/NewPipeExt.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.zionhuang.music.extensions - -import com.zionhuang.music.constants.MediaConstants -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.youtube.NewPipeYouTubeHelper -import org.schabi.newpipe.extractor.stream.StreamInfoItem - -val StreamInfoItem.id: String - get() = NewPipeYouTubeHelper.extractVideoId(url)!! - -fun StreamInfoItem.toSong(): Song = Song( - id = id, - title = name, - artistName = uploaderName, - artworkType = if ("music.youtube.com" in url) MediaConstants.TYPE_SQUARE else MediaConstants.TYPE_RECTANGLE, - duration = duration.toInt() -) 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 f5fa9685a..a9f8d733a 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt @@ -2,9 +2,7 @@ package com.zionhuang.music.extensions import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.Player -import com.zionhuang.music.models.MediaData -import com.zionhuang.music.playback.queues.Queue -import org.schabi.newpipe.extractor.Page +import com.zionhuang.music.models.MediaMetadata fun Player.findMediaItemById(mediaId: String): MediaItem? { for (i in 0 until mediaItemCount) { @@ -27,25 +25,9 @@ fun Player.mediaItemIndexOf(mediaId: String?): Int? { return null } -val Player.currentMetadata: MediaData? +val Player.currentMetadata: MediaMetadata? get() = currentMediaItem?.metadata -suspend fun Player.loadQueue(queue: Queue, mediaId: String?) { - setMediaItems(queue.items) - if (mediaId == null) return - var idx = queue.items.indexOfFirst { it.mediaId == mediaId } - while (idx == -1 && queue.hasNextPage()) { - val lastItemCount = mediaItemCount - val newItems = queue.nextPage().also { - addMediaItems(it) - } - idx = newItems.indexOfFirst { it.mediaId == mediaId } - if (idx != -1) idx += lastItemCount - } - if (idx != -1) seekToDefaultPosition(idx) - -} - val Player.mediaItems: List get() = object : AbstractList() { override val size: Int diff --git a/app/src/main/java/com/zionhuang/music/extensions/RegexExt.kt b/app/src/main/java/com/zionhuang/music/extensions/RegexExt.kt deleted file mode 100644 index 9bc37141d..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/RegexExt.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.zionhuang.music.extensions - -import org.intellij.lang.annotations.Language -import java.util.regex.Matcher -import java.util.regex.Pattern - -fun String.find(@Language("RegExp") regex: String): RegexMatchResult? = - regex.toMatcher(this).findNext(this) - -fun String.find(@Language("RegExp") regex: String, group: String): String? = find(regex)?.groupValue(group) - -fun String.find(@Language("RegExp") regex: String, vararg groups: String): List? = find(regex)?.let { m -> - groups.map { m.groupValue(it) } -} - -fun String.find(@Language("RegExp") regExs: Array): RegexMatchResult? { - for (regex in regExs) { - val res = find(regex) - if (res != null) return res - } - return null -} - -fun String.find(@Language("RegExp") regExs: Array, group: String): String? { - for (regex in regExs) { - val res = find(regex) - if (res != null) return res.groupValue(group) - } - return null -} - -fun String.find(@Language("RegExp") regExs: Array, vararg groups: String): List? { - for (regex in regExs) { - val res = find(regex) - if (res != null) return groups.map { res.groupValue(it) } - } - return null -} - -fun String.findAll(@Language("RegExp") regex: String): Sequence = - generateSequence({ find(regex) }, RegexMatchResult::next) - -fun String.matchEntire(@Language("RegExp") regex: String): RegexMatchResult? = - regex.toMatcher(this).matchEntire(this) - -fun String.matchEntire(@Language("RegExp") regex: String, group: String): String? = matchEntire(regex)?.groupValue(group) - -fun String.matchEntire(@Language("RegExp") regex: String, vararg groups: String): List? = matchEntire(regex)?.let { m -> - groups.map { m.groupValue(it) } -} - -fun String.matchEntire(@Language("RegExp") regExs: Array): RegexMatchResult? { - for (regex in regExs) { - val res = matchEntire(regex) - if (res != null) return res - } - return null -} - -fun String.matchEntire(@Language("RegExp") regExs: Array, group: String): String? { - for (regex in regExs) { - val res = matchEntire(regex) - if (res != null) return res.groupValue(group) - } - return null -} - -fun String.matchEntire(@Language("RegExp") regExs: Array, vararg groups: String): List? { - for (regex in regExs) { - val res = matchEntire(regex) - if (res != null) return groups.map { res.groupValue(it) } - } - return null -} - -fun String.search(@Language("RegExp") regex: String): String? = find(regex)?.let { - if (it.groups.size > 1) it.groupValue(1) else it.groupValue(0) -} - -fun String.search(@Language("RegExp") regExs: Array): String? { - for (regex in regExs) { - val res = search(regex) - if (res != null) return res - } - return null -} - -/** - * Implementation - */ -private fun String.toMatcher(input: CharSequence) = - Pattern.compile(this).matcher(input) - -private fun Matcher.findNext(input: CharSequence, from: Int = 0): RegexMatchResult? = - if (!find(from)) null else RegexMatchResult(this, input) - -private fun Matcher.matchEntire(input: CharSequence): RegexMatchResult? = - if (!matches()) null else RegexMatchResult(this, input) - -/** - * MatcherMatchResult - * Duplicated from [kotlin.text.MatcherMatchResult], but add named group support - */ -class RegexMatchResult(private val matcher: Matcher, private val input: CharSequence) { - private val indexRange = 0 until matcher.groupCount() + 1 - val range = matcher.range() - val value: String = matcher.group() - - val groups: MatchNamedGroupCollection = object : MatchNamedGroupCollection, AbstractCollection() { - override val size: Int get() = matcher.groupCount() + 1 - override fun isEmpty(): Boolean = false - override fun iterator(): Iterator = indices.asSequence().map { this[it] }.iterator() - - override fun get(index: Int): MatchGroup? = - if (index in indexRange) MatchGroup(matcher.group(index)!!, matcher.range(index)) else null - - override fun get(name: String): MatchGroup? = try { - MatchGroup(matcher.group(name)!!, matcher.range(name)) - } catch (e: Exception) { - null - } - } - - fun groupValue(index: Int): String? = if (index in indexRange) matcher.group(index) else null - fun groupValue(name: String): String? = try { - matcher.group(name) - } catch (e: Exception) { - null - } - - private var _groupValues: List? = null - val groupValues: List - get() = _groupValues ?: object : AbstractList() { - override val size: Int get() = groups.size - override fun get(index: Int): String = matcher.group(index) ?: "" - }.also { _groupValues = it } - - fun next(): RegexMatchResult? { - val nextIndex = matcher.end() + if (matcher.end() == matcher.start()) 1 else 0 - return if (nextIndex <= input.length) matcher.pattern().matcher(input).findNext(input, nextIndex) else null - } -} - -private fun Matcher.range(groupIndex: Int = 0): IntRange = start(groupIndex) until end(groupIndex) -private fun Matcher.range(name: String): IntRange = start(name) until end(name) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/SearchViewExt.kt b/app/src/main/java/com/zionhuang/music/extensions/SearchViewExt.kt deleted file mode 100644 index 018a9012b..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/SearchViewExt.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.zionhuang.music.extensions - -import androidx.appcompat.widget.SearchView -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -data class SearchViewTextEvent(val query: String?, val isSubmitted: Boolean) - -fun SearchView.getQueryTextChangeFlow(): StateFlow { - val query = MutableStateFlow(SearchViewTextEvent(this.query.toString(), false)) - setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(s: String?): Boolean { - query.value = SearchViewTextEvent(s, true) - return true - } - - override fun onQueryTextChange(newText: String?): Boolean { - query.value = SearchViewTextEvent(newText, false) - return true - } - }) - return query -} \ 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 index a4e4f6475..a8dbb4fb2 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt @@ -1,8 +1,9 @@ package com.zionhuang.music.extensions -import android.content.Context import android.content.SharedPreferences -import com.zionhuang.music.utils.preference.Preference +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 @@ -16,6 +17,16 @@ inline fun SharedPreferences.putSerializable(key: String, value: T) 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) @@ -27,7 +38,7 @@ fun SharedPreferences.get(key: String, defaultValue: T): T = when (def } as T operator fun SharedPreferences.set(key: String, value: T) { - edit().apply { + edit { when (value::class) { Boolean::class -> putBoolean(key, value as Boolean) Float::class -> putFloat(key, value as Float) @@ -35,6 +46,28 @@ operator fun SharedPreferences.set(key: String, value: T) { Long::class -> putLong(key, value as Long) String::class -> putString(key, value as String) } - apply() } } + +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 98679019b..7d1da26b0 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt @@ -1,71 +1,9 @@ package com.zionhuang.music.extensions import androidx.sqlite.db.SimpleSQLiteQuery -import com.google.gson.* -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.withContext -import org.apache.commons.text.StringEscapeUtils.unescapeJava -import org.jsoup.Jsoup -import java.net.URLDecoder -import java.util.regex.Pattern - -operator fun String.get(begin: Int, end: Int) = this.substring(begin, end) // s[begin, end) - -// Python-like replacement syntactic sugar -operator fun String.rem(replacement: String) = this.replaceFirst("%s", replacement) - -fun String.escape(): String = Pattern.quote(this) -fun String.unescape(): String = unescapeJava(this) -fun String.removeQuotes(): String = when { - length < 2 -> this - first() == '\"' && last() == '\"' -> this[1, length - 1] - first() == '\'' && last() == '\'' -> this[1, length - 1] - else -> this -} - -fun String.urlDecode(): String = URLDecoder.decode(this, "UTF8") - -/** - * Remove given characters at the beginning and the end of the string. - */ -fun String.strip(chars: String) = replace("""^[$chars]+|[$chars]+$""".toRegex(), "") -fun String.rStrip(chars: String) = replace("""[$chars]+$""".toRegex(), "") - -/** - * Search the given character from the end of the string. - * @param sep the seperater - * @return a [Triple] of the left part of [sep], [sep], and the right part of [sep] if [sep] is in the string, else [this], "", and "". - */ -fun String.rPartition(sep: Char): Triple { - for (i in length - 1 downTo 0) { - if (this[i] == sep) { - return Triple(substring(0, i), sep.toString(), substring(i + 1, length)) - } - } - return Triple(this, "", "") -} - -fun String.trimLineStartSpaces() = lines().joinToString("") { it.trim() } - -@Suppress("BlockingMethodInNonBlockingContext") -suspend fun downloadWebPage(url: String): String = withContext(IO) { - Jsoup.connect(url).timeout(60 * 1000).get().html() -} - -@Suppress("BlockingMethodInNonBlockingContext") -suspend fun downloadPlainText(url: String): String = withContext(IO) { - Jsoup.connect(url).ignoreContentType(true).execute().body() -} - -/** - * JsonElement Extensions - */ -fun String.parseQueryString(): JsonObject = JsonObject().apply { - for (pair in split("&")) { - val idx = pair.indexOf("=") - this[pair[0, idx].urlDecode()] = JsonPrimitive(pair[idx + 1, pair.length].urlDecode()) - } -} +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.google.gson.JsonSyntaxException @Throws(JsonSyntaxException::class) fun String.parseJsonString(): JsonElement = JsonParser.parseString(this) diff --git a/app/src/main/java/com/zionhuang/music/extensions/ViewExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ViewExt.kt index 542978931..bd8d1d3a3 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/ViewExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/ViewExt.kt @@ -1,14 +1,39 @@ 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 + DataBindingUtil.inflate(LayoutInflater.from(context), layoutRes, this, attachToRoot) as T -fun View.getActivity(): Activity? = context.getActivity() \ No newline at end of file +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/YouTubeExt.kt b/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt index 68931f221..46bdfcc0d 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt @@ -1,19 +1,40 @@ package com.zionhuang.music.extensions import androidx.paging.PagingSource.LoadResult -import org.schabi.newpipe.extractor.InfoItem -import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage -import org.schabi.newpipe.extractor.ListInfo -import org.schabi.newpipe.extractor.Page +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 -fun ListInfo.toPage() = LoadResult.Page( - data = relatedItems, - nextKey = nextPage, - prevKey = null +// 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 InfoItemsPage.toPage() = LoadResult.Page( +fun BrowseResult.toPage() = LoadResult.Page( data = items, - nextKey = nextPage, + nextKey = continuations?.ifEmpty { null }, prevKey = null ) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/AssetDownloadMission.kt b/app/src/main/java/com/zionhuang/music/models/AssetDownloadMission.kt deleted file mode 100644 index 7de78ad0d..000000000 --- a/app/src/main/java/com/zionhuang/music/models/AssetDownloadMission.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zionhuang.music.models - -import android.os.Parcelable -import androidx.annotation.IntDef -import kotlinx.parcelize.Parcelize - -@Parcelize -data class AssetDownloadMission( - @AssetType val type: Int, - val id: String, -) : Parcelable { - companion object { - @IntDef(ASSET_CHANNEL) - @Retention(AnnotationRetention.SOURCE) - annotation class AssetType - - const val ASSET_CHANNEL = 0 - } -} diff --git a/app/src/main/java/com/zionhuang/music/models/HeaderInfoItem.kt b/app/src/main/java/com/zionhuang/music/models/HeaderInfoItem.kt deleted file mode 100644 index dc76b675b..000000000 --- a/app/src/main/java/com/zionhuang/music/models/HeaderInfoItem.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.zionhuang.music.models - -import org.schabi.newpipe.extractor.InfoItem - -class HeaderInfoItem : InfoItem(InfoType.STREAM, -1, "", "HEADER") \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt b/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt index 977901927..da92a33bc 100644 --- a/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt +++ b/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt @@ -1,15 +1,10 @@ package com.zionhuang.music.models import androidx.lifecycle.LiveData -import androidx.paging.PagingSource import kotlinx.coroutines.flow.Flow - -class ListWrapper( - val getList: suspend () -> List = { throw UnsupportedOperationException() }, - val getPagingSource: () -> PagingSource = { throw UnsupportedOperationException() }, - override val getFlow: () -> Flow> = { throw UnsupportedOperationException() }, - override val getLiveData: () -> LiveData> = { throw UnsupportedOperationException() }, -) : DataWrapper>(getValueAsync = getList) { - val pagingSource: PagingSource get() = getPagingSource() -} \ No newline at end of file +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/MediaData.kt b/app/src/main/java/com/zionhuang/music/models/MediaData.kt deleted file mode 100644 index 649eabece..000000000 --- a/app/src/main/java/com/zionhuang/music/models/MediaData.kt +++ /dev/null @@ -1,76 +0,0 @@ -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 android.support.v4.media.MediaMetadataCompat.* -import androidx.core.net.toUri -import androidx.core.os.bundleOf -import com.zionhuang.music.constants.Constants.EMPTY_SONG_ID -import com.zionhuang.music.constants.MediaConstants.ArtworkType -import com.zionhuang.music.constants.MediaConstants.EXTRA_ARTWORK_TYPE -import com.zionhuang.music.constants.MediaConstants.EXTRA_DURATION -import com.zionhuang.music.constants.MediaConstants.TYPE_RECTANGLE -import com.zionhuang.music.constants.MediaConstants.TYPE_SQUARE -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.id -import com.zionhuang.music.models.MediaData.Companion.EMPTY_MEDIA_DESCRIPTION -import com.zionhuang.music.repos.SongRepository -import kotlinx.parcelize.Parcelize -import org.schabi.newpipe.extractor.stream.StreamInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem - -@Parcelize -data class MediaData( - var id: String = EMPTY_SONG_ID, - var title: String = "", - var artist: String = "", - var duration: Int? = null, - var artwork: String? = null, - @ArtworkType - var artworkType: Int = TYPE_SQUARE, -) : Parcelable { - fun pullMediaMetadata(mediaMetadata: MediaMetadataCompat): MediaData = apply { - id = mediaMetadata.getString(METADATA_KEY_MEDIA_ID) ?: EMPTY_SONG_ID - title = mediaMetadata.getString(METADATA_KEY_TITLE).orEmpty() - artist = mediaMetadata.getString(METADATA_KEY_DISPLAY_SUBTITLE).orEmpty() - artwork = mediaMetadata.getString(METADATA_KEY_DISPLAY_ICON_URI) - duration = (mediaMetadata.getLong(METADATA_KEY_DURATION) / 1000).toInt() - artworkType = mediaMetadata.getLong(EXTRA_ARTWORK_TYPE).toInt() - } - - fun toMediaDescription(): MediaDescriptionCompat = builder - .setMediaId(id) - .setTitle(title) - .setSubtitle(artist) - .setDescription(artist) - .setIconUri(artwork?.toUri()) - .setExtras(bundleOf( - EXTRA_ARTWORK_TYPE to artworkType.toLong(), - EXTRA_DURATION to duration - )) - .build() - - companion object { - private val builder = MediaDescriptionCompat.Builder() - - val EMPTY_MEDIA_DESCRIPTION: MediaDescriptionCompat = builder - .setMediaId(EMPTY_SONG_ID) - .build() - } -} - -fun Song.toMediaData(context: Context) = MediaData(id, title, artistName, duration, SongRepository.getSongArtworkFile(id).canonicalPath, artworkType) - -fun StreamInfo.toMediaData() = MediaData(id, name, uploaderName, duration.toInt(), thumbnailUrl, if ("music.youtube.com" in url) TYPE_SQUARE else TYPE_RECTANGLE) - -fun StreamInfoItem.toMediaData() = - MediaData(id, name, uploaderName, duration.toInt(), thumbnailUrl, if ("music.youtube.com" in url) TYPE_SQUARE else TYPE_RECTANGLE) - -fun MediaDescriptionCompat.toMediaData() = - MediaData(mediaId!!, title.toString(), subtitle.toString(), extras?.getInt(EXTRA_DURATION), iconUri.toString(), extras!!.getInt(EXTRA_ARTWORK_TYPE)) - -fun MediaMetadataCompat.toMediaData(): MediaData = MediaData().pullMediaMetadata(this) - -fun MediaData?.toMediaDescription(): MediaDescriptionCompat = this?.toMediaDescription() ?: EMPTY_MEDIA_DESCRIPTION \ 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 new file mode 100644 index 000000000..fdc601403 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt @@ -0,0 +1,106 @@ +package com.zionhuang.music.models + +import android.os.Parcelable +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ALBUM +import android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ARTIST +import androidx.core.net.toUri +import androidx.core.os.bundleOf +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 kotlinx.parcelize.Parcelize + +@Parcelize +data class MediaMetadata( + val id: String, + val title: String, + val artists: List, + val duration: Int, + val thumbnailUrl: String? = null, + val album: Album? = null, +) : Parcelable { + @Parcelize + data class Artist( + val id: String, + val name: String, + ) : Parcelable + + @Parcelize + data class Album( + val id: String, + val title: String, + val year: Int? = null, + ) : Parcelable + + fun toMediaDescription(): MediaDescriptionCompat = builder + .setMediaId(id) + .setTitle(title) + .setSubtitle(artists.joinToString { it.name }) + .setDescription(artists.joinToString { it.name }) + .setIconUri(thumbnailUrl?.toUri()) + .setExtras(bundleOf( + METADATA_KEY_ARTIST to artists.joinToString { it.name }, + METADATA_KEY_ALBUM to album?.title + )) + .build() + + fun toSongEntity() = SongEntity( + id = id, + title = title, + duration = duration, + thumbnailUrl = thumbnailUrl, + albumId = album?.id, + albumName = album?.title + ) + + companion object { + private val builder = MediaDescriptionCompat.Builder() + } +} + +fun Song.toMediaMetadata() = MediaMetadata( + id = song.id, + title = song.title, + artists = artists.map { + MediaMetadata.Artist( + id = it.id, + name = it.name + ) + }, + duration = song.duration, + thumbnailUrl = song.thumbnailUrl, + album = album?.let { + MediaMetadata.Album( + id = it.id, + title = it.title, + year = it.year + ) + } ?: song.albumId?.let { albumId -> + MediaMetadata.Album( + id = albumId, + title = song.albumName.orEmpty() + ) + } +) + +fun SongItem.toMediaMetadata() = MediaMetadata( + id = id, + title = title, + artists = artists.map { + MediaMetadata.Artist( + id = it.navigationEndpoint?.browseEndpoint?.browseId ?: ArtistEntity.generateArtistId(), + name = it.text + ) + }, + duration = duration ?: -1, + thumbnailUrl = thumbnails.lastOrNull()?.url, + album = album?.let { + MediaMetadata.Album( + id = it.navigationEndpoint.browseId, + title = it.text, + year = albumYear + ) + } +) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/PreferenceSortInfo.kt b/app/src/main/java/com/zionhuang/music/models/PreferenceSortInfo.kt deleted file mode 100644 index 7c827975a..000000000 --- a/app/src/main/java/com/zionhuang/music/models/PreferenceSortInfo.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.zionhuang.music.models - -import androidx.lifecycle.MediatorLiveData -import com.zionhuang.music.R -import com.zionhuang.music.constants.ORDER_NAME -import com.zionhuang.music.models.base.IMutableSortInfo -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.preference -import com.zionhuang.music.extensions.preferenceLiveData - -object PreferenceSortInfo : IMutableSortInfo { - override var type by getApplication().preference(R.string.pref_sort_type, ORDER_NAME) - override var isDescending by getApplication().preference(R.string.pref_sort_descending, true) - private val typeLiveData = getApplication().preferenceLiveData(R.string.pref_sort_type, ORDER_NAME) - private val isDescendingLiveData = getApplication().preferenceLiveData(R.string.pref_sort_descending, true) - - override val liveData = MediatorLiveData().apply { - addSource(typeLiveData) { - value = SortInfo(typeLiveData.value, isDescendingLiveData.value) - } - addSource(isDescendingLiveData) { - value = SortInfo(typeLiveData.value, isDescendingLiveData.value) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/QueueData.kt b/app/src/main/java/com/zionhuang/music/models/QueueData.kt deleted file mode 100644 index 4100e6266..000000000 --- a/app/src/main/java/com/zionhuang/music/models/QueueData.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.zionhuang.music.models - -import android.os.Bundle -import android.os.Parcelable -import com.zionhuang.music.playback.queues.* -import kotlinx.parcelize.Parcelize - -@Parcelize -data class QueueData( - val type: Int, - val queueId: String = "", - val extras: Bundle = Bundle(), - val sortInfo: SortInfo? = null, -) : Parcelable { - suspend fun toQueue() = if (type in map) map[type]!!.invoke(this) else throw IllegalArgumentException("Unknown queue type") - - companion object { - val map = mapOf( - YouTubeSingleSongQueue.TYPE to YouTubeSingleSongQueue.fromParcel, - AllSongQueue.TYPE to AllSongQueue.fromParcel, - ArtistQueue.TYPE to ArtistQueue.fromParcel, - PlaylistQueue.TYPE to PlaylistQueue.fromParcel, - YouTubeSearchQueue.TYPE to YouTubeSearchQueue.fromParcel, - YouTubePlaylistQueue.TYPE to YouTubePlaylistQueue.fromParcel, - YouTubeChannelQueue.TYPE to YouTubeChannelQueue.fromParcel - ) - } -} diff --git a/app/src/main/java/com/zionhuang/music/models/SortInfo.kt b/app/src/main/java/com/zionhuang/music/models/SortInfo.kt deleted file mode 100644 index c4531a3af..000000000 --- a/app/src/main/java/com/zionhuang/music/models/SortInfo.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zionhuang.music.models - -import android.os.Parcelable -import com.zionhuang.music.models.base.ISortInfo -import kotlinx.parcelize.Parcelize - -@Parcelize -data class SortInfo( - override val type: Int, - override val isDescending: Boolean, -) : ISortInfo, Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/base/IMutableSortInfo.kt b/app/src/main/java/com/zionhuang/music/models/base/IMutableSortInfo.kt deleted file mode 100644 index ec5e5731a..000000000 --- a/app/src/main/java/com/zionhuang/music/models/base/IMutableSortInfo.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.zionhuang.music.models.base - -import androidx.lifecycle.LiveData -import com.zionhuang.music.models.SortInfo - -interface IMutableSortInfo : ISortInfo { - override var type: Int - override var isDescending: Boolean - val liveData: LiveData - - fun toggleIsDescending() { - isDescending = !isDescending - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/base/ISortInfo.kt b/app/src/main/java/com/zionhuang/music/models/base/ISortInfo.kt deleted file mode 100644 index 1a581f18a..000000000 --- a/app/src/main/java/com/zionhuang/music/models/base/ISortInfo.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zionhuang.music.models.base - -import com.zionhuang.music.constants.SongSortType -import com.zionhuang.music.models.SortInfo - -interface ISortInfo { - @SongSortType - val type: Int - val isDescending: Boolean - - fun parcelize() = SortInfo(type, isDescending) -} \ No newline at end of file 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 new file mode 100644 index 000000000..f4dfe9f7f --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/models/sortInfo/AlbumSortInfoPreference.kt @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..cae0327d1 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/models/sortInfo/ArtistSortInfoPreference.kt @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..fb5f55143 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/models/sortInfo/ISortInfo.kt @@ -0,0 +1,6 @@ +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 new file mode 100644 index 000000000..b4bf67405 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/models/sortInfo/PlaylistSortInfoPreference.kt @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..038ed011d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/models/sortInfo/SongSortInfoPreference.kt @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..15d87155b --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt @@ -0,0 +1,24 @@ +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 +} + +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 new file mode 100644 index 000000000..1c0655271 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfoPreference.kt @@ -0,0 +1,23 @@ +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 new file mode 100644 index 000000000..7e9f5fd07 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt @@ -0,0 +1,35 @@ +package com.zionhuang.music.playback + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.util.LruCache +import coil.imageLoader +import coil.request.Disposable +import coil.request.ImageRequest + +class BitmapProvider(private val context: Context) { + private val map = LruCache(MAX_CACHE_SIZE) + + private var disposable: Disposable? = null + + fun load(url: String, callback: (Bitmap) -> Unit): Bitmap? { + val cache = map.get(url) + disposable?.dispose() + 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()) + } + return cache + } + + companion object { + const val MAX_CACHE_SIZE = 10 + } +} \ 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 index 2911016cc..54613ebcf 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MediaSessionConnection.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MediaSessionConnection.kt @@ -11,10 +11,9 @@ import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import androidx.lifecycle.MutableLiveData -import com.google.android.exoplayer2.ui.PlayerView import com.zionhuang.music.models.MediaSessionQueueData -import com.zionhuang.music.playback.MusicService.LocalBinder -import java.lang.ref.WeakReference +import com.zionhuang.music.playback.MusicService.MusicBinder +import com.zionhuang.music.utils.livedata.SafeMutableLiveData object MediaSessionConnection { var mediaController: MediaControllerCompat? = null @@ -23,23 +22,20 @@ object MediaSessionConnection { private val mediaControllerCallback = MediaControllerCallback() private var serviceConnection: ServiceConnection? = null - val isConnected = MutableLiveData(false) + val isConnected = SafeMutableLiveData(false) val playbackState = MutableLiveData(null) val nowPlaying = MutableLiveData(null) val queueData = MutableLiveData(MediaSessionQueueData()) - private var weakPlayerView = WeakReference(null) - var playerView: PlayerView? - get() = weakPlayerView.get() - set(value) { - weakPlayerView = WeakReference(value) - } + private var _binder: MusicBinder? = null + val binder: MusicBinder? + get() = _binder fun connect(context: Context) { serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName, iBinder: IBinder) { - if (iBinder !is LocalBinder) return - iBinder.setPlayerView(playerView) + if (iBinder !is MusicBinder) return + _binder = iBinder try { mediaController = MediaControllerCompat(context, iBinder.sessionToken).apply { registerCallback(mediaControllerCallback) @@ -50,6 +46,7 @@ object MediaSessionConnection { } override fun onServiceDisconnected(name: ComponentName) { + _binder = null mediaController?.unregisterCallback(mediaControllerCallback) isConnected.postValue(false) } 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 11620d723..7b330b8a2 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -13,28 +13,27 @@ import android.util.Log import androidx.lifecycle.lifecycleScope import androidx.media.session.MediaButtonReceiver import com.google.android.exoplayer2.ui.PlayerNotificationManager -import com.google.android.exoplayer2.ui.PlayerView class MusicService : LifecycleMediaBrowserService() { companion object { private const val TAG = "MusicService" } - private val binder = LocalBinder() + private val binder = MusicBinder() private lateinit var songPlayer: SongPlayer override fun onCreate() { super.onCreate() songPlayer = SongPlayer(this, lifecycleScope, object : PlayerNotificationManager.NotificationListener { override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) { - stopForeground(true) + stopForeground(STOP_FOREGROUND_REMOVE) } override fun onNotificationPosted(notificationId: Int, notification: Notification, ongoing: Boolean) { if (ongoing) { startForeground(notificationId, notification) } else { - stopForeground(false) + stopForeground(0) } } }) @@ -64,15 +63,12 @@ class MusicService : LifecycleMediaBrowserService() { } } - fun setPlayerView(playerView: PlayerView?) { - songPlayer.setPlayerView(playerView) - } - - internal inner class LocalBinder : Binder() { + inner class MusicBinder : Binder() { val sessionToken: MediaSessionCompat.Token get() = songPlayer.mediaSession.sessionToken - fun setPlayerView(playerView: PlayerView?) = songPlayer.setPlayerView(playerView) + val songPlayer: SongPlayer + get() = this@MusicService.songPlayer } private val ROOT_ID = "root" diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 454183f1e..e5c439d70 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -5,7 +5,6 @@ import android.app.PendingIntent.FLAG_IMMUTABLE import android.content.Context import android.content.Intent import android.graphics.Bitmap -import android.graphics.drawable.Drawable import android.net.ConnectivityManager import android.net.Uri import android.os.Bundle @@ -13,57 +12,47 @@ 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.Log import android.util.Pair import androidx.core.content.getSystemService import androidx.core.net.toUri -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.PlaybackException.ERROR_CODE_REMOTE_ERROR import com.google.android.exoplayer2.Player.* +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.AudioAttributes import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor.* import com.google.android.exoplayer2.source.DefaultMediaSourceFactory import com.google.android.exoplayer2.ui.PlayerNotificationManager -import com.google.android.exoplayer2.ui.PlayerView import com.google.android.exoplayer2.upstream.DefaultDataSource import com.google.android.exoplayer2.upstream.ResolvingDataSource +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.MediaConstants.EXTRA_QUEUE_DATA -import com.zionhuang.music.constants.MediaConstants.EXTRA_SONGS -import com.zionhuang.music.constants.MediaConstants.EXTRA_SONG_ID -import com.zionhuang.music.constants.MediaConstants.QUEUE_YT_SEARCH -import com.zionhuang.music.constants.MediaConstants.QUEUE_YT_SINGLE +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.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_MEDIA_ID -import com.zionhuang.music.db.entities.Song import com.zionhuang.music.extensions.* -import com.zionhuang.music.models.MediaData -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.models.toMediaDescription +import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.playback.queues.EmptyQueue import com.zionhuang.music.playback.queues.Queue import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.repos.YouTubeRepository -import com.zionhuang.music.repos.base.LocalRepository -import com.zionhuang.music.repos.base.RemoteRepository import com.zionhuang.music.ui.activities.MainActivity -import com.zionhuang.music.utils.GlideApp -import com.zionhuang.music.utils.logTimeMillis -import com.zionhuang.music.youtube.NewPipeYouTubeHelper -import com.zionhuang.music.youtube.StreamHelper +import com.zionhuang.music.ui.bindings.resizeThumbnailUrl import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt /** * A wrapper around [ExoPlayer] @@ -72,79 +61,57 @@ class SongPlayer( private val context: Context, private val scope: CoroutineScope, notificationListener: PlayerNotificationManager.NotificationListener, -) : Player.Listener { - private val localRepository: LocalRepository = SongRepository - private val remoteRepository: RemoteRepository = YouTubeRepository +) : Listener, PlaybackStatsListener.Callback { + private val localRepository = SongRepository + 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 currentQueue: Queue = EmptyQueue() - private val _mediaSession = MediaSessionCompat(context, context.getString(R.string.app_name)).apply { + val mediaSession = MediaSessionCompat(context, context.getString(R.string.app_name)).apply { isActive = true } - val mediaSession: MediaSessionCompat get() = _mediaSession val player: ExoPlayer = ExoPlayer.Builder(context) .setMediaSourceFactory( DefaultMediaSourceFactory(ResolvingDataSource.Factory( DefaultDataSource.Factory(context) ) { dataSpec -> - runBlocking { - // TODO Error handling - val mediaId = dataSpec.uri.host ?: throw IllegalArgumentException("Cannot find media id from uri host") - if (localRepository.getSongById(mediaId)?.downloadState == STATE_DOWNLOADED) { - return@runBlocking dataSpec.withUri(localRepository.getSongFile(mediaId).toUri()) - } - val streamInfo = logTimeMillis(TAG, "Extractor duration: %d") { - runBlocking { - remoteRepository.getStream(mediaId) - } - } - val connectivityManager = context.getSystemService()!! - val stream = if (connectivityManager.isActiveNetworkMetered) { - StreamHelper.getMostCompactAudioStream(streamInfo.audioStreams) - } else { - StreamHelper.getHighestQualityAudioStream(streamInfo.audioStreams) + val mediaId = dataSpec.key ?: error("No media id") + val song = runBlocking(IO) { localRepository.getSongById(mediaId) } + if (song?.song?.downloadState == STATE_DOWNLOADED) { + return@Factory dataSpec.withUri(localRepository.getSongFile(mediaId).toUri()) + } + kotlin.runCatching { + runBlocking(IO) { + YouTube.player(mediaId) } - val uri = stream?.url?.toUri() - updateMediaData(mediaId) { - if (artwork == null || (artwork!!.startsWith("http") && artwork != streamInfo.thumbnailUrl)) { - artwork = streamInfo.thumbnailUrl - mediaSessionConnector.invalidateMediaSessionMetadata() - } + }.mapCatching { playerResponse -> + if (playerResponse.playabilityStatus.status != "OK") { + throw PlaybackException(playerResponse.playabilityStatus.status, null, ERROR_CODE_REMOTE_ERROR) } - if (uri != null) dataSpec.withUri(uri) else dataSpec - } + val uri = playerResponse.streamingData?.adaptiveFormats + ?.filter { it.isAudio } + ?.maxByOrNull { it.bitrate * (if (connectivityManager.isActiveNetworkMetered) -1 else 1) } + ?.url + ?.toUri() + ?: throw PlaybackException("No stream available", null, ERROR_CODE_NO_STREAM) + dataSpec.withUri(uri) + }.getOrThrow() }) ) .build() .apply { addListener(this@SongPlayer) + addAnalyticsListener(PlaybackStatsListener(false, this@SongPlayer)) val audioAttributes = AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) - .setContentType(C.CONTENT_TYPE_MUSIC) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .build() setAudioAttributes(audioAttributes, true) setHandleAudioBecomingNoisy(true) } - private var autoAddSong by context.preference(R.string.pref_auto_add_song, true) - - private fun updateMediaData(mediaId: String, applier: MediaData.() -> Unit) { - scope.launch(Dispatchers.Main) { - (player.currentMediaItem.takeIf { it?.mediaId == mediaId } ?: player.findMediaItemById(mediaId))?.metadata?.let { - applier(it) - } - } - } - - private fun playMedia(mediaId: String?, playWhenReady: Boolean, queueData: QueueData) { - scope.launch { - currentQueue = queueData.toQueue() - player.loadQueue(currentQueue, mediaId) - player.prepare() - player.playWhenReady = playWhenReady - } - } - private val mediaSessionConnector = MediaSessionConnector(mediaSession).apply { setPlayer(player) setPlaybackPreparer(object : MediaSessionConnector.PlaybackPreparer { @@ -159,25 +126,29 @@ class SongPlayer( player.prepare() } - override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) = - playMedia(mediaId, playWhenReady, extras!!.getParcelable(EXTRA_QUEUE_DATA)!!) + override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { + // TODO + } override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { - val mediaId = extras?.getString(EXTRA_SONG_ID) - playMedia(mediaId, playWhenReady, QueueData(QUEUE_YT_SEARCH, query)) + // TODO } override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { - val mediaId = NewPipeYouTubeHelper.extractVideoId(uri.toString()) ?: return setCustomErrorMessage( - "Can't extract video id from the url.", - ERROR_CODE_UNKNOWN_ERROR - ) - playMedia(mediaId, playWhenReady, QueueData(QUEUE_YT_SINGLE, mediaId)) + // TODO } }) 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 -> { val mediaId = extras.getString(EXTRA_MEDIA_ID) ?: return@registerCustomCommandReceiver true @@ -187,17 +158,15 @@ class SongPlayer( true } COMMAND_PLAY_NEXT -> { - val songs = extras.getParcelableArray(EXTRA_SONGS)!! player.addMediaItems( - (if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex) + 1, - songs.mapNotNull { (it as? MediaData)?.toMediaItem() } + 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 -> { - val songs = extras.getParcelableArray(EXTRA_SONGS)!! - player.addMediaItems(songs.mapNotNull { (it as? MediaData)?.toMediaItem() }) + player.addMediaItems(extras.getParcelableArray(EXTRA_MEDIA_METADATA_ITEMS)!!.filterIsInstance().map { it.toMediaItem() }) player.prepare() true } @@ -209,27 +178,12 @@ class SongPlayer( addToLibrary(it) } }) - setQueueNavigator { player, windowIndex -> player.getMediaItemAt(windowIndex).metadata.toMediaDescription() } - setErrorMessageProvider { e -> - return@setErrorMessageProvider Pair(ERROR_CODE_UNKNOWN_ERROR, e.localizedMessage) - } + setQueueNavigator { player, windowIndex -> player.getMediaItemAt(windowIndex).metadata!!.toMediaDescription() } + setErrorMessageProvider { e -> Pair(ERROR_CODE_UNKNOWN_ERROR, e.localizedMessage) } setQueueEditor(object : MediaSessionConnector.QueueEditor { - override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean { - if (COMMAND_MOVE_QUEUE_ITEM != command || extras == null) return false - 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) - } - return true - } - - override fun onAddQueueItem(player: Player, description: MediaDescriptionCompat) = - player.addMediaItem(description.toMediaItem()) - - override fun onAddQueueItem(player: Player, description: MediaDescriptionCompat, index: Int) = - player.addMediaItem(index, description.toMediaItem()) - + 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) @@ -244,27 +198,14 @@ class SongPlayer( player.currentMetadata?.title.orEmpty() override fun getCurrentContentText(player: Player): CharSequence? = - player.currentMetadata?.artist + player.currentMetadata?.artists?.joinToString { it.name } - override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? { - val url = player.currentMetadata?.artwork - val bitmap = GlideApp.with(context) - .asBitmap() - .load(url) - .onlyRetrieveFromCache(true) - .getBlocking() - if (bitmap == null) { - GlideApp.with(context) - .asBitmap() - .load(url) - .onlyRetrieveFromCache(false) - .into(object : CustomTarget() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) = callback.onBitmap(resource) - override fun onLoadCleared(placeholder: Drawable?) = Unit - }) + override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? = + player.currentMetadata?.thumbnailUrl?.let { url -> + bitmapProvider.load(resizeThumbnailUrl(url, (256 * context.resources.displayMetrics.density).roundToInt(), null)) { + callback.onBitmap(it) + } } - return bitmap - } override fun createCurrentContentIntent(player: Player): PendingIntent? = PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), FLAG_IMMUTABLE) @@ -278,6 +219,61 @@ class SongPlayer( setSmallIcon(R.drawable.ic_notification) } + + fun playQueue(queue: Queue) { + currentQueue = queue + player.clearMediaItems() + + scope.launch { + val initialStatus = withContext(IO) { queue.getInitialStatus() } + player.setMediaItems(initialStatus.items) + if (initialStatus.index > 0) player.seekToDefaultPosition(initialStatus.index) + player.prepare() + player.playWhenReady = true + } + } + + fun handleQueueAddEndpoint(endpoint: QueueAddEndpoint, item: YTItem?) { + scope.launch { + val items = when (item) { + is SongItem -> YouTube.getQueue(videoIds = listOf(item.id)).map { it.toMediaItem() } + is AlbumItem -> withContext(IO) { + YouTube.browse(BrowseEndpoint(browseId = "VL" + item.playlistId)).items.filterIsInstance().map { it.toMediaItem() } + // consider refetch by [YouTube.getQueue] if needed + } + is PlaylistItem -> withContext(IO) { + YouTube.getQueue(playlistId = endpoint.queueTarget.playlistId!!).map { it.toMediaItem() } + } + is ArtistItem -> return@launch + null -> when { + endpoint.queueTarget.videoId != null -> withContext(IO) { + YouTube.getQueue(videoIds = listOf(endpoint.queueTarget.videoId!!)).map { it.toMediaItem() } + } + endpoint.queueTarget.playlistId != null -> withContext(IO) { + YouTube.getQueue(playlistId = endpoint.queueTarget.playlistId).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() + } + /** * Auto load more */ @@ -292,7 +288,7 @@ class SongPlayer( } } - override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, @Player.DiscontinuityReason reason: Int) { + override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, @DiscontinuityReason reason: Int) { if (reason == DISCONTINUITY_REASON_AUTO_TRANSITION && autoAddSong) { oldPosition.mediaItem?.metadata?.let { addToLibrary(it) @@ -308,15 +304,16 @@ class SongPlayer( } } - private fun addToLibrary(mediaData: MediaData) { + override fun onPlaybackStatsReady(eventTime: AnalyticsListener.EventTime, playbackStats: PlaybackStats) { + val mediaItem = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem scope.launch { - localRepository.addSong(Song( - id = mediaData.id, - title = mediaData.title, - artistName = mediaData.artist, - duration = if (player.duration != C.TIME_UNSET) (player.duration / 1000).toInt() else -1, - artworkType = mediaData.artworkType - )) + SongRepository.incrementSongTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) + } + } + + private fun addToLibrary(mediaMetadata: MediaMetadata) { + scope.launch { + localRepository.addSong(mediaMetadata) } } @@ -330,28 +327,10 @@ class SongPlayer( player.release() } - fun setPlayerView(playerView: PlayerView?) { - playerView?.player = player - } - - init { - context.getLifeCycleOwner()?.let { lifeCycleOwner -> - // TODO -// oldSongRepository.deletedSongs.observe(lifeCycleOwner) { deletedSongs -> -// Log.d(TAG, deletedSongs.toString()) -// val deletedIds = deletedSongs.map { it.songId } -// player.mediaItems.forEachIndexed { index, mediaItem -> -// if (mediaItem.mediaId in deletedIds) { -// player.removeMediaItem(index) -// } -// } -// } - } - } - companion object { - const val TAG = "SongPlayer" const val CHANNEL_ID = "music_channel_01" const val NOTIFICATION_ID = 888 + + const val ERROR_CODE_NO_STREAM = 1000001 } } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/AllSongQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/AllSongQueue.kt deleted file mode 100644 index eea898f0f..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/queues/AllSongQueue.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.zionhuang.music.playback.queues - -import com.google.android.exoplayer2.MediaItem -import com.zionhuang.music.constants.MediaConstants.QUEUE_ALL_SONG -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.toMediaItems -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.repos.SongRepository - -class AllSongQueue( - override val items: List, -) : Queue { - override fun hasNextPage() = false - override suspend fun nextPage() = emptyList() - - companion object { - const val TYPE = QUEUE_ALL_SONG - val fromParcel: suspend (QueueData) -> Queue = { - AllSongQueue(SongRepository.getAllSongs(it.sortInfo!!).getList().toMediaItems(getApplication())) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/ArtistQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/ArtistQueue.kt deleted file mode 100644 index df2c3f7f8..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/queues/ArtistQueue.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.zionhuang.music.playback.queues - -import com.google.android.exoplayer2.MediaItem -import com.zionhuang.music.constants.MediaConstants.EXTRA_ARTIST_ID -import com.zionhuang.music.constants.MediaConstants.QUEUE_ARTIST -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.toMediaItems -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.repos.SongRepository - -class ArtistQueue( - override val items: List, -) : Queue { - override fun hasNextPage() = false - override suspend fun nextPage() = emptyList() - - companion object { - const val TYPE = QUEUE_ARTIST - val fromParcel: suspend (QueueData) -> Queue = { - ArtistQueue(SongRepository.getArtistSongs( - it.extras.getInt(EXTRA_ARTIST_ID), - it.sortInfo!! - ).getList().toMediaItems(getApplication())) - } - } -} \ 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 54740d9c4..d03657453 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 @@ -3,7 +3,8 @@ package com.zionhuang.music.playback.queues import com.google.android.exoplayer2.MediaItem class EmptyQueue : Queue { - override val items: List = emptyList() + override val title: String? = null + override suspend fun getInitialStatus() = Queue.Status(emptyList(), -1) override fun hasNextPage() = false override suspend fun nextPage() = emptyList() } \ No newline at end of file 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 new file mode 100644 index 000000000..7f108c3e7 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/playback/queues/ListQueue.kt @@ -0,0 +1,15 @@ +package com.zionhuang.music.playback.queues + +import com.google.android.exoplayer2.MediaItem + +class ListQueue( + override val title: String? = null, + val items: List, + val startIndex: Int = 0, +) : Queue { + override suspend fun getInitialStatus() = Queue.Status(items, startIndex) + + 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/PlaylistQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/PlaylistQueue.kt deleted file mode 100644 index 46ad9d9f5..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/queues/PlaylistQueue.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.zionhuang.music.playback.queues - -import com.google.android.exoplayer2.MediaItem -import com.zionhuang.music.constants.MediaConstants.EXTRA_PLAYLIST_ID -import com.zionhuang.music.constants.MediaConstants.QUEUE_PLAYLIST -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.toMediaItems -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.repos.SongRepository - -class PlaylistQueue( - override val items: List, -) : Queue { - override fun hasNextPage() = false - override suspend fun nextPage() = emptyList() - - companion object { - const val TYPE = QUEUE_PLAYLIST - val fromParcel: suspend (QueueData) -> Queue = { it -> - PlaylistQueue(SongRepository.getPlaylistSongs(it.extras.getInt(EXTRA_PLAYLIST_ID), it.sortInfo!!).getList().toMediaItems(getApplication())) - } - } -} \ 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 c22968b74..7e0955d80 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 @@ -3,7 +3,13 @@ package com.zionhuang.music.playback.queues import com.google.android.exoplayer2.MediaItem interface Queue { - val items: List + val title: String? + suspend fun getInitialStatus(): Status fun hasNextPage(): Boolean suspend fun nextPage(): List + + data class Status( + val items: List, + val index: Int, + ) } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeChannelQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeChannelQueue.kt deleted file mode 100644 index 041aa8cdc..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeChannelQueue.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.zionhuang.music.playback.queues - -import com.google.android.exoplayer2.MediaItem -import com.zionhuang.music.constants.MediaConstants.QUEUE_YT_CHANNEL -import com.zionhuang.music.extensions.toMediaItems -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.youtube.NewPipeYouTubeHelper -import org.schabi.newpipe.extractor.Page -import org.schabi.newpipe.extractor.Page.isValid - -class YouTubeChannelQueue( - private val channelId: String, - override val items: List, - page: Page?, -) : Queue { - private var nextPage: Page? = page - override fun hasNextPage() = isValid(nextPage) - override suspend fun nextPage(): List = if (hasNextPage()) { - NewPipeYouTubeHelper.getChannel(channelId, nextPage!!).also { nextPage = it.nextPage }.items.toMediaItems() - } else emptyList() - - companion object { - const val TYPE = QUEUE_YT_CHANNEL - val fromParcel: suspend (QueueData) -> Queue = { - val initialInfo = NewPipeYouTubeHelper.getChannel(it.queueId) - YouTubeChannelQueue( - it.queueId, - initialInfo.relatedItems.toMediaItems(), - initialInfo.nextPage - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubePlaylistQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubePlaylistQueue.kt deleted file mode 100644 index 6af674d7a..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubePlaylistQueue.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.zionhuang.music.playback.queues - -import com.google.android.exoplayer2.MediaItem -import com.zionhuang.music.constants.MediaConstants.QUEUE_YT_PLAYLIST -import com.zionhuang.music.extensions.toMediaItems -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.youtube.NewPipeYouTubeHelper -import org.schabi.newpipe.extractor.Page -import org.schabi.newpipe.extractor.Page.isValid - -class YouTubePlaylistQueue( - private val playlistId: String, - override val items: List, - page: Page? -) : Queue { - private var nextPage: Page? = page - override fun hasNextPage() = isValid(nextPage) - override suspend fun nextPage(): List = if (hasNextPage()) { - NewPipeYouTubeHelper.getPlaylist(playlistId, nextPage!!).also { nextPage = it.nextPage }.items.toMediaItems() - } else emptyList() - - companion object { - const val TYPE = QUEUE_YT_PLAYLIST - val fromParcel: suspend (QueueData) -> Queue = { - val initialInfo = NewPipeYouTubeHelper.getPlaylist(it.queueId) - YouTubePlaylistQueue( - it.queueId, - initialInfo.relatedItems.toMediaItems(), - initialInfo.nextPage - ) - } - } -} \ No newline at end of file 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 new file mode 100644 index 000000000..8b7da0111 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt @@ -0,0 +1,35 @@ +package com.zionhuang.music.playback.queues + +import com.google.android.exoplayer2.MediaItem +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.YTItem +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.extensions.toMediaItem +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext + +class YouTubeQueue( + private val endpoint: WatchEndpoint, + val item: YTItem? = null, +) : Queue { + override val title: String? = null + + private var continuation: String? = null + + override suspend fun getInitialStatus(): Queue.Status { + val nextResult = withContext(IO) { YouTube.next(endpoint, continuation) } + continuation = nextResult.continuation + return Queue.Status( + items = nextResult.items.map { it.toMediaItem() }, + index = nextResult.currentIndex ?: 0 + ) + } + + override fun hasNextPage(): Boolean = continuation != null + + override suspend fun nextPage(): List { + val nextResult = withContext(IO) { YouTube.next(endpoint, continuation) } + 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/playback/queues/YouTubeSearchQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeSearchQueue.kt deleted file mode 100644 index 773232d65..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeSearchQueue.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.zionhuang.music.playback.queues - -import com.google.android.exoplayer2.MediaItem -import com.zionhuang.music.constants.MediaConstants.EXTRA_SEARCH_FILTER -import com.zionhuang.music.constants.MediaConstants.QUEUE_YT_SEARCH -import com.zionhuang.music.extensions.toMediaItems -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.youtube.NewPipeYouTubeHelper -import org.schabi.newpipe.extractor.Page -import org.schabi.newpipe.extractor.Page.isValid - -class YouTubeSearchQueue( - private val query: String, - private val filter: String, - override val items: List, - page: Page? -) : Queue { - private var nextPage: Page? = page - override fun hasNextPage() = isValid(nextPage) - override suspend fun nextPage(): List = if (hasNextPage()) { - NewPipeYouTubeHelper.search(query, listOf(filter), nextPage!!).also { nextPage = it.nextPage }.items.toMediaItems() - } else emptyList() - - companion object { - const val TYPE = QUEUE_YT_SEARCH - val fromParcel: suspend (QueueData) -> Queue = { - val initialInfo = NewPipeYouTubeHelper.search(it.queueId, listOf(it.extras.getString(EXTRA_SEARCH_FILTER)!!)) - YouTubeSearchQueue( - it.queueId, - it.extras.getString(EXTRA_SEARCH_FILTER)!!, - initialInfo.relatedItems.toMediaItems(), - initialInfo.nextPage - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeSingleSongQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeSingleSongQueue.kt deleted file mode 100644 index c1847510c..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeSingleSongQueue.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.zionhuang.music.playback.queues - -import com.google.android.exoplayer2.MediaItem -import com.zionhuang.music.constants.MediaConstants.QUEUE_YT_SINGLE -import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.youtube.NewPipeYouTubeHelper - -class YouTubeSingleSongQueue( - override val items: List, -) : Queue { - override fun hasNextPage() = false - override suspend fun nextPage() = emptyList() - - companion object { - const val TYPE = QUEUE_YT_SINGLE - val fromParcel: suspend (QueueData) -> Queue = { - YouTubeSingleSongQueue(listOf(NewPipeYouTubeHelper.getStreamInfo(it.queueId).toMediaItem())) - } - } -} \ 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 index d68cabe12..72ca11193 100644 --- a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt +++ b/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt @@ -1,234 +1,600 @@ package com.zionhuang.music.repos import android.app.DownloadManager -import android.util.Log 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.daos.ArtistDao -import com.zionhuang.music.db.daos.DownloadDao -import com.zionhuang.music.db.daos.PlaylistDao -import com.zionhuang.music.db.daos.SongDao import com.zionhuang.music.db.entities.* -import com.zionhuang.music.extensions.TAG -import com.zionhuang.music.extensions.div -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.preference +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.PreferenceSortInfo -import com.zionhuang.music.models.base.ISortInfo +import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.models.sortInfo.* import com.zionhuang.music.repos.base.LocalRepository -import com.zionhuang.music.repos.base.RemoteRepository -import com.zionhuang.music.utils.OkHttpDownloader +import com.zionhuang.music.ui.bindings.resizeThumbnailUrl import com.zionhuang.music.utils.md5 -import com.zionhuang.music.youtube.NewPipeYouTubeHelper -import com.zionhuang.music.youtube.StreamHelper 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 object SongRepository : LocalRepository { private val context = getApplication() - private val musicDatabase = MusicDatabase.getInstance(context) - private val songDao: SongDao = musicDatabase.songDao - private val artistDao: ArtistDao = musicDatabase.artistDao - private val playlistDao: PlaylistDao = musicDatabase.playlistDao - private val downloadDao: DownloadDao = musicDatabase.downloadDao - private val remoteRepository: RemoteRepository = YouTubeRepository + 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 var autoDownload by context.preference(R.string.pref_auto_download, false) - override suspend fun getSongById(songId: String): Song? = withContext(IO) { songDao.getSong(songId) } - override fun searchSongs(query: String) = ListWrapper( - getPagingSource = { songDao.searchSongsAsPagingSource(query) } + /** + * Browse + */ + override fun getAllSongs(sortInfo: ISortInfo): ListWrapper = ListWrapper( + getList = { withContext(IO) { songDao.getAllSongsAsList(sortInfo) } }, + getFlow = { + if (sortInfo.type != SongSortType.ARTIST) { + songDao.getAllSongsAsFlow(sortInfo) + } else { + 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) + } + } + } ) - override fun hasSong(songId: String): DataWrapper = DataWrapper( - getValueAsync = { songDao.hasSong(songId) }, - getLiveData = { songDao.hasSongLiveData(songId).distinctUntilChanged() } + override suspend fun getSongCount() = withContext(IO) { songDao.getSongCount() } + + override fun getAllArtists(sortInfo: ISortInfo) = ListWrapper( + getFlow = { + if (sortInfo.type != ArtistSortType.SONG_COUNT) { + artistDao.getAllArtistsAsFlow(sortInfo) + } else { + artistDao.getAllArtistsAsFlow(SortInfo(ArtistSortType.CREATE_DATE, true)).map { list -> + list.sortedBy { it.songCount }.reversed(sortInfo.isDescending) + } + } + } ) - override suspend fun addSongs(songs: List) = songs.forEach { - if (songDao.hasSong(it.id)) return@forEach - try { - val stream = NewPipeYouTubeHelper.getStreamInfo(it.id) - OkHttpDownloader.downloadFile(stream.thumbnailUrl, getSongArtworkFile(it.id)) - songDao.insert(listOf(it.toSongEntity().copy( - duration = if (it.duration == -1) stream.duration.toInt() else it.duration) - )) - if (autoDownload) { - downloadSong(it.id) + override suspend fun getArtistCount() = withContext(IO) { artistDao.getArtistCount() } + + override suspend fun getArtistSongsPreview(artistId: String): List = withContext(IO) { + 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)) + } 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) } - } catch (e: Exception) { - // TODO: Handle error - Log.d(TAG, e.localizedMessage.orEmpty()) } + ) + + override suspend fun getAlbumCount() = withContext(IO) { albumDao.getAlbumCount() } + override suspend fun getAlbumSongs(albumId: String) = withContext(IO) { + songDao.getAlbumSongs(albumId) } - override suspend fun updateSongs(songs: List) = withContext(IO) { songDao.update(songs.map { it.toSongEntity() }) } + 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 suspend fun getPlaylistCount() = withContext(IO) { playlistDao.getPlaylistCount() } - override suspend fun moveSongsToTrash(songs: List) = updateSongs(songs.map { it.copy(isTrash = true) }) + override fun getPlaylistSongs(playlistId: String): ListWrapper = ListWrapper( + getList = { withContext(IO) { songDao.getPlaylistSongsAsList(playlistId) } }, + getFlow = { songDao.getPlaylistSongsAsFlow(playlistId) } + ) - override suspend fun restoreSongsFromTrash(songs: List) = updateSongs(songs.map { it.copy(isTrash = false) }) + /** + * 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 suspend fun deleteSongs(songs: List) = withContext(IO) { - songDao.delete(songs.map { it.id }) - songs.forEach { - getSongFile(it.id).delete() - getSongArtworkFile(it.id).delete() + override fun searchSongs(query: String) = songDao.searchSongs(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) = withContext(IO) { + if (getSongById(mediaMetadata.id) != null) return@withContext + 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) } - override suspend fun setLiked(liked: Boolean, songs: List) = updateSongs(songs.map { it.copy(liked = liked) }) + 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 downloadSongs(songIds: List) = songIds.forEach { id -> - // the given songs should be already added to the local repository - val song = getSongById(id) ?: return@forEach - if (song.downloadState != STATE_NOT_DOWNLOADED) return@forEach - updateSong(song.copy(downloadState = STATE_PREPARING)) - try { - val streamInfo = remoteRepository.getStream(id) - updateSong(song.copy(downloadState = STATE_DOWNLOADING)) - // TODO Exception handling - val stream = StreamHelper.getHighestQualityAudioStream(streamInfo.audioStreams)!! - val downloadManager = context.getSystemService()!! - val req = DownloadManager.Request(stream.content.toUri()) - .setTitle(song.title) - .setDestinationUri(getSongFile(id).toUri()) - .setVisibleInDownloadsUi(false) - val did = downloadManager.enqueue(req) - addDownload(DownloadEntity(did, id)) - } catch (e: Exception) { - updateSong(song.copy(downloadState = STATE_NOT_DOWNLOADED)) + override suspend fun safeAddSongs(songs: List): List = withContext(IO) { + // call [YouTube.getQueue] to ensure we get full information + songs.chunked(MAX_GET_QUEUE_SIZE).flatMap { chunk -> + addSongs(YouTube.getQueue(chunk.map { it.id })) } } - override suspend fun removeDownloads(songIds: List) = songIds.forEach { songId -> - val song = getSongById(songId) ?: return@forEach - if (song.downloadState != STATE_DOWNLOADED) return@forEach - if (!getSongFile(songId).exists() || getSongFile(songId).delete()) { - updateSong(song.copy(downloadState = STATE_NOT_DOWNLOADED)) + override suspend fun refetchSongs(songs: List) { + val map = songs.associateBy { it.id } + val songItems = songs.chunked(MAX_GET_QUEUE_SIZE).flatMap { chunk -> + YouTube.getQueue(chunk.map { it.id }) } + songDao.update(songItems.map { item -> + item.toSongEntity().copy( + downloadState = map[item.id]!!.song.downloadState, + createDate = map[item.id]!!.song.createDate + ) + }) + 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 suspend fun getSongById(songId: String): Song? = withContext(IO) { songDao.getSong(songId) } override fun getSongFile(songId: String): File { val mediaDir = context.getExternalFilesDir(null)!! / "media" if (!mediaDir.isDirectory) mediaDir.mkdirs() return mediaDir / md5(songId) } - override fun getSongArtworkFile(songId: String): File { - val artworkDir = context.getExternalFilesDir(null)!! / "artwork" - if (!artworkDir.isDirectory) artworkDir.mkdirs() - return artworkDir / 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 getAllSongs(sortInfo: ISortInfo): ListWrapper = ListWrapper( - getList = { withContext(IO) { songDao.getAllSongsAsList(sortInfo) } }, - getPagingSource = { songDao.getAllSongsAsPagingSource(sortInfo) } + override fun hasSong(songId: String): DataWrapper = DataWrapper( + getValueAsync = { songDao.hasSong(songId) }, + getLiveData = { songDao.hasSongAsLiveData(songId).distinctUntilChanged() } ) - override fun getArtistSongs(artistId: Int, sortInfo: ISortInfo): ListWrapper = ListWrapper( - getList = { withContext(IO) { songDao.getArtistSongsAsList(artistId, sortInfo) } }, - getPagingSource = { songDao.getArtistSongsAsPagingSource(artistId, sortInfo) } - ) + override suspend fun incrementSongTotalPlayTime(songId: String, playTime: Long) = withContext(IO) { + songDao.incrementSongTotalPlayTime(songId, playTime) + } - override fun getPlaylistSongs(playlistId: Int, sortInfo: ISortInfo): ListWrapper = ListWrapper( - getList = { withContext(IO) { songDao.getPlaylistSongsAsList(playlistId) } }, - getPagingSource = { songDao.getPlaylistSongsAsPagingSource(playlistId) } - ) + override suspend fun updateSongTitle(song: Song, newTitle: String) = withContext(IO) { + songDao.update(song.song.copy(title = newTitle, modifyDate = LocalDateTime.now())) + } + override suspend fun setLiked(liked: Boolean, songs: List) = withContext(IO) { + songDao.update(songs.map { it.song.copy(liked = liked, modifyDate = LocalDateTime.now()) }) + } - override fun getAllArtists() = ListWrapper( - getList = { withContext(IO) { artistDao.getAllArtistsAsList() } }, - getPagingSource = { artistDao.getAllArtistsAsPagingSource() } - ) + 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 playerResponse = YouTube.player(videoId = song.id) + if (playerResponse.playabilityStatus.status == "OK") { + val url = playerResponse.streamingData?.adaptiveFormats + ?.filter { it.isAudio } + ?.maxByOrNull { it.bitrate } + ?.url + if (url == null) { + songDao.update(song.copy(downloadState = STATE_NOT_DOWNLOADED)) + // TODO + } else { + songDao.update(song.copy(downloadState = STATE_DOWNLOADING)) + val downloadManager = context.getSystemService()!! + val req = DownloadManager.Request(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 getArtistById(artistId: Int): ArtistEntity? = withContext(IO) { artistDao.getArtistById(artistId) } - override suspend fun getArtistByName(name: String): ArtistEntity? = withContext(IO) { artistDao.getArtistByName(name) } - override fun searchArtists(query: String) = ListWrapper( - getList = { withContext(IO) { artistDao.searchArtists(query) } } - ) + override suspend fun onDownloadComplete(downloadId: Long, success: Boolean): Unit = withContext(IO) { + getDownloadEntity(downloadId)?.songId?.let { songId -> + getSongById(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 addArtist(artist: ArtistEntity) { - withContext(IO) { - artistDao.insert(artist) + 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 updateArtist(artist: ArtistEntity) = withContext(IO) { artistDao.update(artist) } - override suspend fun deleteArtist(artist: ArtistEntity) = withContext(IO) { artistDao.delete(artist) } - override suspend fun mergeArtists(from: Int, to: Int): Unit = withContext(IO) { - val destArtist = getArtistById(to) ?: return@withContext - updateSongs(getArtistSongs(from, PreferenceSortInfo).getList().map { - it.copy(artistName = destArtist.name) - }) - getArtistById(from)?.let { deleteArtist(it) } + 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 fun getAllPlaylists() = ListWrapper( - getList = { withContext(IO) { playlistDao.getAllPlaylistsAsList() } }, - getPagingSource = { playlistDao.getAllPlaylistsAsPagingSource() } - ) + override suspend fun deleteSongs(songs: List) = withContext(IO) { + val renewPlaylists = playlistDao.getPlaylistSongMaps(songs.map { it.id }).groupBy { it.playlistId }.mapValues { entry -> + entry.value.minOf { it.position } - 1 + } + songDao.delete(songs.map { it.song }) + songs.forEach { song -> + getSongFile(song.song.id).delete() + } + artistDao.delete(songs + .flatMap { it.artists } + .distinctBy { it.id } + .filter { artistDao.getArtistSongCount(it.id) == 0 }) + renewPlaylists.forEach { (playlistId, position) -> + playlistDao.renewSongPositions(playlistId, position) + } + } - override suspend fun getPlaylistById(playlistId: Int): PlaylistEntity? = withContext(IO) { playlistDao.getPlaylist(playlistId) } - override fun searchPlaylists(query: String) = ListWrapper( - getList = { withContext(IO) { playlistDao.searchPlaylists(query) } } - ) + /** + * 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 addPlaylist(playlist: PlaylistEntity) = withContext(IO) { playlistDao.insertPlaylist(playlist) } - override suspend fun updatePlaylist(playlist: PlaylistEntity) = withContext(IO) { playlistDao.updatePlaylist(playlist) } - override suspend fun deletePlaylist(playlist: PlaylistEntity) = withContext(IO) { playlistDao.deletePlaylist(playlist) } + override suspend fun refetchArtist(artist: ArtistEntity) = withContext(IO) { + if (artist.isYouTubeArtist) { + val browseResult = YouTube.browse(BrowseEndpoint(browseId = artist.id)) + 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) + } + override suspend fun deleteArtists(artists: List) = withContext(IO) { + // TODO + artistDao.delete(artists) + } - override suspend fun getPlaylistSongEntities(playlistId: Int) = ListWrapper( - getList = { withContext(IO) { playlistDao.getPlaylistSongEntities(playlistId) } } - ) + /** + * Album + */ + override suspend fun addAlbums(albums: List) = withContext(IO) { + albums.forEach { album -> + val ids = YouTube.browse(BrowseEndpoint(browseId = "VL" + album.playlistId)).items.filterIsInstance().map { it.id } + YouTube.getQueue(videoIds = ids).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)).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 refetchAlbum(album: AlbumEntity): Unit = withContext(IO) { + (YouTube.browse(BrowseEndpoint(browseId = album.id)).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 deleteAlbums(albums: List) = withContext(IO) { + albums.forEach { album -> + val songs = songDao.getAlbumSongs(album.id) + albumDao.delete(album.album) + deleteSongs(songs) + } + } + + /** + * 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)).items.firstOrNull() as? AlbumOrPlaylistHeader)?.let { header -> + playlistDao.insert(header.toPlaylistEntity()) + } + } + } - override suspend fun updatePlaylistSongEntities(playlistSongEntities: List) = withContext(IO) { playlistDao.updatePlaylistSongEntities(playlistSongEntities) } + 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)).filterIsInstance() + safeAddSongs(songs) + playlistDao.insert(songs.map { + PlaylistSongMap( + playlistId = playlistId, + songId = it.id, + position = index++ + ) + }) + } + } - override suspend fun addSongsToPlaylist(playlistId: Int, songs: List) { + private suspend fun addSongsToPlaylist(playlistId: String, songIds: List) { var maxId = playlistDao.getPlaylistMaxId(playlistId) ?: -1 - playlistDao.insertPlaylistSongEntities(songs.map { - PlaylistSongEntity( + playlistDao.insert(songIds.map { songId -> + PlaylistSongMap( playlistId = playlistId, - songId = it.id, - idInPlaylist = ++maxId + songId = songId, + position = ++maxId ) }) } - override suspend fun removeSongsFromPlaylist(playlistId: Int, idInPlaylist: List) = withContext(IO) { playlistDao.deletePlaylistSongEntities(playlistId, idInPlaylist) } + 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)).filterIsInstance()).map { it.id } + } + } + } + addSongsToPlaylist(playlist.id, songIds) + } + override suspend fun addToPlaylist(playlist: PlaylistEntity, item: YTItem) = withContext(IO) { + if (playlist.isYouTubePlaylist) return@withContext + val songs = when (item) { + is ArtistItem -> return@withContext + is SongItem -> YouTube.getQueue(videoIds = listOf(item.id)) + is AlbumItem -> YouTube.browse(BrowseEndpoint(browseId = "VL" + item.playlistId)).items.filterIsInstance() // consider refetch by [YouTube.getQueue] if needed + is PlaylistItem -> YouTube.browseAll(BrowseEndpoint(browseId = "VL" + item.id)).filterIsInstance() + } + addSongs(songs) + addSongsToPlaylist(playlist.id, songs.map { it.id }) + } - override fun getAllDownloads() = ListWrapper( - getLiveData = { downloadDao.getAllDownloadEntitiesAsLiveData() } - ) + override suspend fun addYouTubeItemsToPlaylist(playlist: PlaylistEntity, items: List) { + val songs = items.flatMap { item -> + when (item) { + is SongItem -> listOf(item) + is AlbumItem -> withContext(IO) { + YouTube.browse(BrowseEndpoint(browseId = "VL" + item.playlistId)).items.filterIsInstance() + // consider refetch by [YouTube.getQueue] if needed + } + is PlaylistItem -> withContext(IO) { + YouTube.getQueue(playlistId = item.id) + } + is ArtistItem -> emptyList() + } + } + addSongs(songs) + addSongsToPlaylist(playlist.id, songs.map { it.id }) + } + + override suspend fun refetchPlaylist(playlist: Playlist): Unit = withContext(IO) { + (YouTube.browse(BrowseEndpoint(browseId = "VL" + playlist.id)).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 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 addDownload(item: DownloadEntity) = withContext(IO) { downloadDao.insert(item) } override suspend fun removeDownloadEntity(downloadId: Long) = withContext(IO) { downloadDao.delete(downloadId) } + /** + * Search history + */ + override suspend fun getAllSearchHistory() = withContext(IO) { + database.searchHistoryDao.getAllHistory() + } - private suspend fun Song.toSongEntity() = SongEntity( - id, - title, - withContext(IO) { artistDao.getArtistId(artistName) ?: artistDao.insert(ArtistEntity(name = artistName)).toInt() }, - duration, - liked, - artworkType, - isTrash, - downloadState, - createDate, - modifyDate - ) + override suspend fun getSearchHistory(query: String) = withContext(IO) { + database.searchHistoryDao.getHistory(query) + } + + override suspend fun insertSearchHistory(query: String) = withContext(IO) { + database.searchHistoryDao.insert(SearchHistory(query = query)) + } + + override suspend fun deleteSearchHistory(query: String) = withContext(IO) { + database.searchHistoryDao.delete(query) + } } \ 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 index c7e890f2f..69ad8a5c2 100644 --- a/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt +++ b/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt @@ -2,53 +2,91 @@ package com.zionhuang.music.repos 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.repos.base.RemoteRepository -import com.zionhuang.music.youtube.NewPipeYouTubeHelper -import org.schabi.newpipe.extractor.InfoItem -import org.schabi.newpipe.extractor.Page -import org.schabi.newpipe.extractor.stream.StreamInfo - -object YouTubeRepository : RemoteRepository { - override fun search(query: String, filter: String): PagingSource = object : PagingSource() { - @Suppress("BlockingMethodInNonBlockingContext") - override suspend fun load(params: LoadParams) = try { - if (params.key == null) NewPipeYouTubeHelper.search(query, listOf(filter)).toPage() - else NewPipeYouTubeHelper.search(query, listOf(filter), params.key!!).toPage() - } catch (e: Exception) { - LoadResult.Error(e) +import com.zionhuang.music.utils.InfoCache.checkCache +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext + +object YouTubeRepository { + fun searchAll(query: String) = object : PagingSource, YTBaseItem>() { + override suspend fun load(params: LoadParams>) = withContext(IO) { + try { + YouTube.searchAllType(query).toPage() + } catch (e: Exception) { + e.printStackTrace() + LoadResult.Error(e) + } } - override fun getRefreshKey(state: PagingState): Page? = null + override fun getRefreshKey(state: PagingState, YTBaseItem>): List? = null } - override suspend fun suggestionsFor(query: String): List = NewPipeYouTubeHelper.suggestionsFor(query) - - override suspend fun getStream(songId: String): StreamInfo = NewPipeYouTubeHelper.getStreamInfo(songId) - - override fun getChannel(channelId: String) = object : PagingSource() { - override suspend fun load(params: LoadParams) = try { - if (params.key == null) NewPipeYouTubeHelper.getChannel(channelId).toPage() - else NewPipeYouTubeHelper.getChannel(channelId, params.key!!).toPage() - } catch (e: Exception) { - LoadResult.Error(e) + 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) + } else { + YouTube.search(params.key!![0]) + }.toPage() + } catch (e: Exception) { + e.printStackTrace() + LoadResult.Error(e) + } } - override fun getRefreshKey(state: PagingState): Page? = null + override fun getRefreshKey(state: PagingState, YTBaseItem>): List? = null } - override fun getPlaylist(playlistId: String) = object : PagingSource() { - override suspend fun load(params: LoadParams) = try { - if (params.key == null) NewPipeYouTubeHelper.getPlaylist(playlistId).toPage() - else NewPipeYouTubeHelper.getPlaylist(playlistId, params.key!!).toPage() - } catch (e: Exception) { - LoadResult.Error(e) + 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) + 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) { + // 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)) + } + ) + } else { + browseResult + } + } else { + YouTube.browse(params.key!!) + }.toPage() + } catch (e: Exception) { + e.printStackTrace() + LoadResult.Error(e) + } } - override fun getRefreshKey(state: PagingState): Page? = null + override fun getRefreshKey(state: PagingState, YTBaseItem>): List? = null } - override fun getAlbum(albumId: String) { - TODO("Not yet implemented") + suspend fun getSuggestions(query: String): List = withContext(IO) { + checkCache("SU$query") { + YouTube.getSearchSuggestions(query) + } } } \ 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 index ced6f308b..50194f470 100644 --- a/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt +++ b/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt @@ -1,58 +1,116 @@ 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.base.ISortInfo +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): List + 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 + suspend fun getPlaylistCount(): Int + + fun getPlaylistSongs(playlistId: String): ListWrapper + + /** + * Search + */ + fun searchAll(query: String): Flow> + fun searchSongs(query: String): Flow> + fun searchArtists(query: String): Flow> + fun searchAlbums(query: String): Flow> + fun searchPlaylists(query: String): Flow> + + /** + * Song + */ + suspend fun addSong(mediaMetadata: MediaMetadata) + suspend fun safeAddSong(song: SongItem) = safeAddSongs(listOf(song)) + suspend fun safeAddSongs(songs: List): List + suspend fun refetchSongs(songs: List) suspend fun getSongById(songId: String): Song? - fun searchSongs(query: String): ListWrapper + fun getSongFile(songId: String): File fun hasSong(songId: String): DataWrapper - suspend fun addSongs(songs: List) - suspend fun addSong(song: Song) = addSongs(listOf(song)) - suspend fun updateSongs(songs: List) - suspend fun updateSong(song: Song) = updateSongs(listOf(song)) - suspend fun moveSongsToTrash(songs: List) - suspend fun restoreSongsFromTrash(songs: List) - suspend fun deleteSongs(songs: List) + suspend fun incrementSongTotalPlayTime(songId: String, playTime: Long) + suspend fun updateSongTitle(song: Song, newTitle: String) suspend fun setLiked(liked: Boolean, songs: List) - suspend fun downloadSongs(songIds: List) - suspend fun downloadSong(songId: String) = downloadSongs(listOf(songId)) - suspend fun removeDownloads(songIds: List) - suspend fun removeDownload(songId: String) = removeDownloads(listOf(songId)) - fun getSongFile(songId: String): File - fun getSongArtworkFile(songId: String): File - - fun getAllSongs(sortInfo: ISortInfo): ListWrapper - fun getArtistSongs(artistId: Int, sortInfo: ISortInfo): ListWrapper - fun getPlaylistSongs(playlistId: Int, sortInfo: ISortInfo): ListWrapper + suspend fun downloadSong(song: SongEntity) = downloadSongs(listOf(song)) + suspend fun downloadSongs(songs: List) + suspend fun onDownloadComplete(downloadId: Long, success: Boolean) + suspend fun removeDownloads(songs: List) + suspend fun moveToTrash(songs: List) + suspend fun restoreFromTrash(songs: List) + suspend fun deleteSongs(songs: List) - fun getAllArtists(): ListWrapper - suspend fun getArtistById(artistId: Int): ArtistEntity? + /** + * Artist + */ + suspend fun getArtistById(artistId: String): ArtistEntity? suspend fun getArtistByName(name: String): ArtistEntity? - fun searchArtists(query: String): ListWrapper - suspend fun addArtist(artist: ArtistEntity) + suspend fun refetchArtist(artist: ArtistEntity) suspend fun updateArtist(artist: ArtistEntity) - suspend fun deleteArtist(artist: ArtistEntity) - suspend fun mergeArtists(from: Int, to: Int) + suspend fun deleteArtist(artist: ArtistEntity) = deleteArtists(listOf(artist)) + suspend fun deleteArtists(artists: List) - fun getAllPlaylists(): ListWrapper - suspend fun getPlaylistById(playlistId: Int): PlaylistEntity? - fun searchPlaylists(query: String): ListWrapper - suspend fun addPlaylist(playlist: PlaylistEntity) - suspend fun updatePlaylist(playlist: PlaylistEntity) - suspend fun deletePlaylist(playlist: PlaylistEntity) + /** + * Album + */ + suspend fun addAlbum(album: AlbumItem) = addAlbums(listOf(album)) + suspend fun addAlbums(albums: List) + suspend fun refetchAlbum(album: AlbumEntity) + suspend fun deleteAlbums(albums: List) - suspend fun getPlaylistSongEntities(playlistId: Int): ListWrapper - suspend fun updatePlaylistSongEntities(playlistSongEntities: List) - suspend fun addSongsToPlaylist(playlistId: Int, songs: List) - suspend fun removeSongsFromPlaylist(playlistId: Int, idInPlaylist: List) - suspend fun removeSongFromPlaylist(playlistId: Int, idInPlaylist: Int) = removeSongsFromPlaylist(playlistId, listOf(idInPlaylist)) + /** + * 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, items: List) + suspend fun addToPlaylist(playlist: PlaylistEntity, item: YTItem) + suspend fun addYouTubeItemsToPlaylist(playlist: PlaylistEntity, items: List) + suspend fun refetchPlaylist(playlist: Playlist) + 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) - fun getAllDownloads(): ListWrapper + /** + * Download + */ + suspend fun addDownloadEntity(item: DownloadEntity) suspend fun getDownloadEntity(downloadId: Long): DownloadEntity? - suspend fun addDownload(item: 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) } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/repos/base/RemoteRepository.kt b/app/src/main/java/com/zionhuang/music/repos/base/RemoteRepository.kt deleted file mode 100644 index f1cb5a212..000000000 --- a/app/src/main/java/com/zionhuang/music/repos/base/RemoteRepository.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.zionhuang.music.repos.base - -import androidx.paging.PagingSource -import org.schabi.newpipe.extractor.InfoItem -import org.schabi.newpipe.extractor.Page -import org.schabi.newpipe.extractor.stream.StreamInfo - -interface RemoteRepository { - fun search(query: String, filter: String): PagingSource - suspend fun suggestionsFor(query: String): List - - @Throws(UnsupportedOperationException::class) - suspend fun getStream(songId: String): StreamInfo - - @Throws(UnsupportedOperationException::class) - fun getChannel(channelId: String): PagingSource - - @Throws(UnsupportedOperationException::class) - fun getPlaylist(playlistId: String): PagingSource - - @Throws(UnsupportedOperationException::class) - fun getAlbum(albumId: String) -} \ 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 index 48c4032c8..eb120dbcc 100644 --- a/app/src/main/java/com/zionhuang/music/ui/activities/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/ui/activities/MainActivity.kt @@ -1,54 +1,58 @@ 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.os.Bundle -import android.util.Log import android.view.ActionMode import android.view.View -import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment import androidx.interpolator.view.animation.FastOutSlowInInterpolator -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.NavigationUI.onNavDestinationSelected import androidx.navigation.ui.setupWithNavController import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.* 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.MediaConstants.EXTRA_QUEUE_DATA -import com.zionhuang.music.constants.MediaConstants.QUEUE_YT_SINGLE import com.zionhuang.music.databinding.ActivityMainBinding -import com.zionhuang.music.extensions.TAG import com.zionhuang.music.extensions.dip +import com.zionhuang.music.extensions.preference import com.zionhuang.music.extensions.replaceFragment -import com.zionhuang.music.models.QueueData +import com.zionhuang.music.playback.MediaSessionConnection +import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.activities.base.ThemedBindingActivity import com.zionhuang.music.ui.fragments.BottomControlsFragment +import com.zionhuang.music.ui.fragments.base.AbsRecyclerViewFragment import com.zionhuang.music.ui.widgets.BottomSheetListener -import com.zionhuang.music.viewmodels.PlaybackViewModel -import com.zionhuang.music.viewmodels.SongsViewModel -import com.zionhuang.music.youtube.NewPipeYouTubeHelper.extractVideoId -import com.zionhuang.music.youtube.NewPipeYouTubeHelper.getLinkType +import com.zionhuang.music.utils.NavigationEndpointHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.schabi.newpipe.extractor.StreamingService.LinkType class MainActivity : ThemedBindingActivity(), NavController.OnDestinationChangedListener { override fun getViewBinding() = ActivityMainBinding.inflate(layoutInflater) - private var bottomSheetCallback: BottomSheetListener? = null - private lateinit var bottomSheetBehavior: BottomSheetBehavior<*> + private lateinit var navHostFragment: NavHostFragment + private val currentFragment: Fragment? + get() = navHostFragment.childFragmentManager.fragments.firstOrNull() - private val songsViewModel by lazy { ViewModelProvider(this)[SongsViewModel::class.java] } - private val playbackViewModel by lazy { ViewModelProvider(this)[PlaybackViewModel::class.java] } + private var bottomSheetCallback: BottomSheetListener? = null + lateinit var bottomSheetBehavior: BottomSheetBehavior<*> val fab: FloatingActionButton get() = binding.fab @@ -58,17 +62,6 @@ class MainActivity : ThemedBindingActivity(), NavController super.onCreate(savedInstanceState) setupUI() handleIntent(intent) - // TODO -// songsViewModel.deletedSongs.observe(this) { songs -> -// Snackbar.make(binding.root, resources.getQuantityString(R.plurals.snack_bar_delete_song, songs.size, songs.size), Snackbar.LENGTH_LONG) -// .setAnchorView(binding.bottomNav) -// .setAction(R.string.snack_bar_undo) { -// lifecycleScope.launch { -// songsViewModel.songRepository.restoreSongs(songs) -// } -// } -// .show() -// } } override fun onNewIntent(intent: Intent?) { @@ -77,43 +70,50 @@ class MainActivity : ThemedBindingActivity(), NavController } private fun handleIntent(intent: Intent) { - // Handle url - val url = (intent.data ?: intent.getStringExtra(EXTRA_TEXT)).toString() - Log.d(TAG, "${intent.action} ${url}") - when (getLinkType(url)) { - LinkType.STREAM -> { - lifecycleScope.launch { - while (playbackViewModel.mediaSessionIsConnected.value == false) delay(100) - val videoId = extractVideoId(url)!! - playbackViewModel.playMedia(this@MainActivity, videoId, bundleOf( - EXTRA_QUEUE_DATA to QueueData(QUEUE_YT_SINGLE, queueId = videoId) - )) - } + 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))) } - LinkType.CHANNEL -> {} - LinkType.PLAYLIST -> {} - LinkType.NONE -> {} + 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() } private fun setupUI() { - setSupportActionBar(binding.toolbar) - val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment + navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment val navController = navHostFragment.navController - val appBarConfiguration = AppBarConfiguration(setOf( - R.id.songsFragment, - R.id.artistsFragment, - R.id.playlistsFragment, - R.id.explorationFragment - )) - binding.toolbar.setupWithNavController(navController, appBarConfiguration) + navController.addOnDestinationChangedListener(this) binding.bottomNav.setupWithNavController(navController) binding.bottomNav.setOnItemSelectedListener { item -> - onNavDestinationSelected(item, navController) - item.isChecked = true + if (item.isChecked) { + // scroll to top + (currentFragment as? AbsRecyclerViewFragment<*, *>)?.getRecyclerView()?.smoothScrollToPosition(0) + } else { + onNavDestinationSelected(item, navController) + item.isChecked = true + } true } - navController.addOnDestinationChangedListener(this) replaceFragment(R.id.bottom_controls_container, BottomControlsFragment()) bottomSheetBehavior = from(binding.bottomControlsSheet).apply { @@ -124,14 +124,29 @@ class MainActivity : ThemedBindingActivity(), NavController } 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) + } } + @SuppressLint("PrivateResource") override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) bottomSheetBehavior.setPeekHeight(dip(R.dimen.m3_bottom_nav_min_height) + dip(R.dimen.bottom_controls_sheet_peek_height), true) @@ -141,13 +156,14 @@ class MainActivity : ThemedBindingActivity(), NavController bottomSheetCallback = bottomSheetListener } - fun expandBottomSheet() { - bottomSheetBehavior.state = STATE_EXPANDED - } - - fun showBottomSheet(force: Boolean = false) { - if (bottomSheetBehavior.state == STATE_HIDDEN || force) { - bottomSheetBehavior.state = STATE_COLLAPSED + 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 + } } } @@ -179,6 +195,8 @@ class MainActivity : ThemedBindingActivity(), NavController } } + @Suppress("DEPRECATION") + @Deprecated("Deprecated in Java") override fun onBackPressed() { if (bottomSheetBehavior.state == STATE_EXPANDED) { bottomSheetBehavior.state = STATE_COLLAPSED 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 new file mode 100644 index 000000000..52453c650 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/activities/SettingsActivity.kt @@ -0,0 +1,24 @@ +package com.zionhuang.music.ui.activities + +import android.os.Bundle +import androidx.navigation.findNavController +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 +// binding.toolbar.setupWithNavController(navController) + 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/ThemedBindingActivity.kt b/app/src/main/java/com/zionhuang/music/ui/activities/base/ThemedBindingActivity.kt index b688fc6b6..508d0e187 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -10,7 +11,7 @@ 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() { +abstract class ThemedBindingActivity : BindingActivity(), SharedPreferences.OnSharedPreferenceChangeListener { override fun onCreate(savedInstanceState: Bundle?) { // Fix preference type mismatch in 0.3.0 try { @@ -27,6 +28,17 @@ abstract class ThemedBindingActivity : BindingActivity() { } 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/ArtistsAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/ArtistsAdapter.kt deleted file mode 100644 index c01d8b010..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/ArtistsAdapter.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.view.ViewGroup -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import com.zionhuang.music.R -import com.zionhuang.music.db.entities.ArtistEntity -import com.zionhuang.music.extensions.inflateWithBinding -import com.zionhuang.music.ui.listeners.ArtistPopupMenuListener -import com.zionhuang.music.ui.viewholders.ArtistViewHolder - -class ArtistsAdapter : PagingDataAdapter(ArtistItemComparator()) { - var popupMenuListener: ArtistPopupMenuListener? = null - override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { - getItem(position)?.let { holder.bind(it) } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArtistViewHolder = - ArtistViewHolder(parent.inflateWithBinding(R.layout.item_artist), popupMenuListener) - - fun getItemByPosition(position: Int): ArtistEntity? = getItem(position) - - internal class ArtistItemComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ArtistEntity, newItem: ArtistEntity): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: ArtistEntity, newItem: ArtistEntity): Boolean = oldItem.name == newItem.name - override fun getChangePayload(oldItem: ArtistEntity, newItem: ArtistEntity): ArtistEntity = newItem - } -} \ 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 new file mode 100644 index 000000000..4cf6bbb65 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/adapters/DraggableLocalItemAdapter.kt @@ -0,0 +1,158 @@ +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.repos.SongRepository +import com.zionhuang.music.ui.listeners.IAlbumMenuListener +import com.zionhuang.music.ui.listeners.IArtistMenuListener +import com.zionhuang.music.ui.listeners.IPlaylistMenuListener +import com.zionhuang.music.ui.listeners.ISongMenuListener +import com.zionhuang.music.ui.viewholders.* +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.time.Duration +import java.time.LocalDateTime + +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 tracker: SelectionTracker? = null + var allowMoreAction: Boolean = true // for choosing playlist + var onShuffle: () -> Unit = {} + + var itemTouchHelper: ItemTouchHelper? = null + var isDraggable: Boolean = false // for reorder playlist + + @OptIn(DelicateCoroutinesApi::class) + 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) + if (item.artist.bannerUrl == null || Duration.between(item.artist.lastUpdateTime, LocalDateTime.now()) > Duration.ofDays(10)) { + GlobalScope.launch { + SongRepository.refetchArtist(item.artist) + } + } + } + is AlbumViewHolder -> { + holder.bind(item as Album, tracker?.isSelected(getItem(position).id) ?: false) + if (item.album.thumbnailUrl == null || item.album.year == null) { + GlobalScope.launch { + SongRepository.refetchAlbum(item.album) + } + } + } + is PlaylistViewHolder -> holder.bind(item as Playlist, tracker?.isSelected(getItem(position).id) ?: false) + 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_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 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_SONG_HEADER = 4 + const val TYPE_ARTIST_HEADER = 5 + const val TYPE_ALBUM_HEADER = 6 + const val TYPE_PLAYLIST_HEADER = 7 + const val TYPE_PLAYLIST_SONG_HEADER = 8 + const val TYPE_TEXT_HEADER = 9 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/InfoItemAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/InfoItemAdapter.kt deleted file mode 100644 index 684521eaf..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/InfoItemAdapter.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.view.ViewGroup -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import com.zionhuang.music.R -import com.zionhuang.music.extensions.inflateWithBinding -import com.zionhuang.music.ui.listeners.StreamPopupMenuListener -import com.zionhuang.music.ui.viewholders.SearchChannelViewHolder -import com.zionhuang.music.ui.viewholders.SearchPlaylistViewHolder -import com.zionhuang.music.ui.viewholders.SearchStreamViewHolder -import com.zionhuang.music.ui.viewholders.base.SearchViewHolder -import org.schabi.newpipe.extractor.InfoItem -import org.schabi.newpipe.extractor.channel.ChannelInfoItem -import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem -import org.schabi.newpipe.extractor.stream.StreamInfoItem - -class InfoItemAdapter : PagingDataAdapter(InfoItemComparator()) { - var streamMenuListener: StreamPopupMenuListener? = null - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchViewHolder = when (viewType) { - ITEM_VIDEO -> SearchStreamViewHolder(parent.inflateWithBinding(R.layout.item_search_stream), streamMenuListener) - ITEM_CHANNEL -> SearchChannelViewHolder(parent.inflateWithBinding(R.layout.item_search_channel)) - ITEM_PLAYLIST -> SearchPlaylistViewHolder(parent.inflateWithBinding(R.layout.item_search_playlist)) - else -> throw IllegalArgumentException("Unexpected item type.") - } - - override fun onBindViewHolder(holder: SearchViewHolder, position: Int) { - when (holder) { - is SearchStreamViewHolder -> holder.bind(getItem(position) as StreamInfoItem) - is SearchChannelViewHolder -> holder.bind(getItem(position) as ChannelInfoItem) - is SearchPlaylistViewHolder -> holder.bind(getItem(position) as PlaylistInfoItem) - } - } - - override fun getItemViewType(position: Int): Int = when (getItem(position)) { - is StreamInfoItem -> ITEM_VIDEO - is ChannelInfoItem -> ITEM_CHANNEL - is PlaylistInfoItem -> ITEM_PLAYLIST - else -> -1 - } - - fun getItemByPosition(position: Int) = getItem(position) - - class InfoItemComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: InfoItem, newItem: InfoItem): Boolean = oldItem.url == newItem.url - override fun areContentsTheSame(oldItem: InfoItem, newItem: InfoItem): Boolean = true - } - - companion object { - const val ITEM_HEADER = 0 - const val ITEM_VIDEO = 1 - const val ITEM_CHANNEL = 2 - const val ITEM_PLAYLIST = 3 - } -} \ 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 new file mode 100644 index 000000000..cfff7cbbb --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/adapters/LocalItemAdapter.kt @@ -0,0 +1,166 @@ +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.repos.SongRepository +import com.zionhuang.music.ui.listeners.IAlbumMenuListener +import com.zionhuang.music.ui.listeners.IArtistMenuListener +import com.zionhuang.music.ui.listeners.IPlaylistMenuListener +import com.zionhuang.music.ui.listeners.ISongMenuListener +import com.zionhuang.music.ui.viewholders.* +import com.zionhuang.music.utils.makeTimeString +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import me.zhanghai.android.fastscroll.PopupTextProvider +import java.time.Duration +import java.time.LocalDateTime +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 tracker: SelectionTracker? = null + var allowMoreAction: Boolean = true // for choosing playlist + var onShuffle: () -> Unit = {} + + var itemTouchHelper: ItemTouchHelper? = null + var isDraggable: Boolean = false // for reorder playlist + + @OptIn(DelicateCoroutinesApi::class) + 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) + if (item.artist.bannerUrl == null || Duration.between(item.artist.lastUpdateTime, LocalDateTime.now()) > Duration.ofDays(10)) { + GlobalScope.launch { + SongRepository.refetchArtist(item.artist) + } + } + } + is AlbumViewHolder -> { + holder.bind(item as Album, tracker?.isSelected(getItem(position).id) ?: false) + if (item.album.thumbnailUrl == null || item.album.year == null) { + GlobalScope.launch { + SongRepository.refetchAlbum(item.album) + } + } + } + is PlaylistViewHolder -> holder.bind(item as Playlist, tracker?.isSelected(getItem(position).id) ?: false) + 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_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 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 + } + 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() + } + } ?: "" + + 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_SONG_HEADER = 4 + const val TYPE_ARTIST_HEADER = 5 + const val TYPE_ALBUM_HEADER = 6 + const val TYPE_PLAYLIST_HEADER = 7 + const val TYPE_PLAYLIST_SONG_HEADER = 8 + const val TYPE_TEXT_HEADER = 9 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/PlaylistSongsAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/PlaylistSongsAdapter.kt deleted file mode 100644 index c78e88968..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/PlaylistSongsAdapter.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.annotation.SuppressLint -import android.view.ViewGroup -import androidx.lifecycle.LiveData -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.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.HEADER_ITEM_ID -import com.zionhuang.music.constants.Constants.TYPE_HEADER -import com.zionhuang.music.constants.Constants.TYPE_ITEM -import com.zionhuang.music.constants.MediaConstants.STATE_DOWNLOADING -import com.zionhuang.music.constants.ORDER_ARTIST -import com.zionhuang.music.constants.ORDER_CREATE_DATE -import com.zionhuang.music.constants.ORDER_NAME -import com.zionhuang.music.models.base.IMutableSortInfo -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.inflateWithBinding -import com.zionhuang.music.models.DownloadProgress -import com.zionhuang.music.ui.listeners.SongPopupMenuListener -import com.zionhuang.music.ui.viewholders.SongHeaderViewHolder -import com.zionhuang.music.ui.viewholders.SongViewHolder -import me.zhanghai.android.fastscroll.PopupTextProvider -import java.text.DateFormat - -class PlaylistSongsAdapter : PagingDataAdapter(SongItemComparator()), PopupTextProvider { - var popupMenuListener: SongPopupMenuListener? = null - var sortInfo: IMutableSortInfo? = null - var downloadInfo: LiveData>? = null - var tracker: SelectionTracker? = null - var itemTouchHelper: ItemTouchHelper? = null - - private val moves: MutableList> = mutableListOf() - var onProcessMove: ((List>) -> Unit)? = null - - fun moveItem(from: Int, to: Int) { - moves.add(Pair(from, to)) - notifyItemMoved(from, to) - } - - fun processMove() { - onProcessMove?.let { - it(moves.toList()) - moves.clear() - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is SongViewHolder -> getItem(position)?.let { song -> - holder.bind(song, tracker?.isSelected(song.id)) - if (song.downloadState == STATE_DOWNLOADING) { - downloadInfo?.value?.get(song.id)?.let { info -> - holder.setProgress(info, false) - } - } - } - is SongHeaderViewHolder -> holder.bind(itemCount - 1) - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List) { - when (holder) { - is SongViewHolder -> { - if (payloads.isEmpty()) { - onBindViewHolder(holder, position) - } else when (val payload = payloads[0]) { - SELECTION_CHANGED_MARKER -> holder.onSelectionChanged( - tracker?.isSelected( - holder.binding.song?.id - ) - ) - is Song -> holder.bind(payload) - is DownloadProgress -> holder.setProgress(payload) - } - } - is SongHeaderViewHolder -> holder.bind(itemCount - 1) - } - } - - @SuppressLint("ClickableViewAccessibility") - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = - when (viewType) { - TYPE_HEADER -> SongHeaderViewHolder(parent.inflateWithBinding(R.layout.item_song_header), sortInfo!!) - TYPE_ITEM -> SongViewHolder(parent.inflateWithBinding(R.layout.item_song), popupMenuListener) - else -> throw IllegalArgumentException("Unexpected view type.") - } - - fun getItemByPosition(position: Int): Song? = getItem(position) - - fun setProgress(id: String, progress: DownloadProgress) { - snapshot().items.forEachIndexed { index, song -> - if (song.id == id) { - notifyItemChanged(index, progress) - } - } - } - - override fun getItemViewType(position: Int): Int = - if (getItem(position)?.id == HEADER_ITEM_ID) TYPE_HEADER else TYPE_ITEM - - private val dateFormat = DateFormat.getDateInstance() - - override fun getPopupText(position: Int): String = - if (getItemViewType(position) == TYPE_HEADER) "#" - else getItem(position)?.let { - when (sortInfo!!.type) { - ORDER_CREATE_DATE -> dateFormat.format(it.createDate) - ORDER_NAME -> it.title[0].toString() - ORDER_ARTIST -> it.artistName - else -> it.title[0].toString() - } - } ?: "" - - class SongItemComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Song, newItem: Song): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean = oldItem == newItem - override fun getChangePayload(oldItem: Song, newItem: Song): Song = newItem - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/PlaylistSongsEditAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/PlaylistSongsEditAdapter.kt deleted file mode 100644 index aceb31925..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/PlaylistSongsEditAdapter.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.annotation.SuppressLint -import android.view.MotionEvent -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.HEADER_ITEM_ID -import com.zionhuang.music.constants.Constants.TYPE_HEADER -import com.zionhuang.music.constants.Constants.TYPE_ITEM -import com.zionhuang.music.models.base.IMutableSortInfo -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.inflateWithBinding -import com.zionhuang.music.models.DownloadProgress -import com.zionhuang.music.ui.viewholders.DraggableSongViewHolder -import com.zionhuang.music.ui.viewholders.SongHeaderViewHolder -import com.zionhuang.music.ui.viewholders.SongViewHolder - -class PlaylistSongsEditAdapter : ListAdapter(SongItemComparator()) { - var sortInfo: IMutableSortInfo? = null - var itemTouchHelper: ItemTouchHelper? = null - - private val moves: MutableList> = mutableListOf() - var onProcessMove: ((List>) -> Unit)? = null - - fun moveItem(from: Int, to: Int) { - moves.add(Pair(from, to)) - notifyItemMoved(from, to) - } - - fun processMove() { - onProcessMove?.let { - it(moves.toList()) - moves.clear() - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is SongViewHolder -> holder.bind(getItem(position)!!, false) - is SongHeaderViewHolder -> holder.bind(itemCount - 1) - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List) { - when (holder) { - is SongViewHolder -> { - if (payloads.isEmpty()) { - onBindViewHolder(holder, position) - } else when (val payload = payloads[0]) { - is Song -> holder.bind(payload) - is DownloadProgress -> holder.setProgress(payload) - } - } - is SongHeaderViewHolder -> holder.bind(itemCount - 1) - } - } - - @SuppressLint("ClickableViewAccessibility") - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = - when (viewType) { - TYPE_HEADER -> SongHeaderViewHolder(parent.inflateWithBinding(R.layout.item_song_header), sortInfo!!) - TYPE_ITEM -> DraggableSongViewHolder(parent.inflateWithBinding(R.layout.item_song)).apply { - binding.dragHandle.setOnTouchListener { _, event -> - if (event.actionMasked == MotionEvent.ACTION_DOWN) { - itemTouchHelper?.startDrag(this) - } - true - } - } - else -> throw IllegalArgumentException("Unexpected view type.") - } - - fun getItemByPosition(position: Int): Song? = getItem(position) - - override fun getItemViewType(position: Int): Int = - if (getItem(position)?.id == HEADER_ITEM_ID) TYPE_HEADER else TYPE_ITEM - - class SongItemComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Song, newItem: Song): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean = oldItem == newItem - override fun getChangePayload(oldItem: Song, newItem: Song): Song = newItem - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/PlaylistsAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/PlaylistsAdapter.kt deleted file mode 100644 index 93ec034aa..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/PlaylistsAdapter.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.view.ViewGroup -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import com.zionhuang.music.R -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.extensions.inflateWithBinding -import com.zionhuang.music.ui.listeners.PlaylistPopupMenuListener -import com.zionhuang.music.ui.viewholders.PlaylistViewHolder - -class PlaylistsAdapter : PagingDataAdapter(PlaylistItemComparator()) { - var popupMenuListener: PlaylistPopupMenuListener? = null - override fun onBindViewHolder(holder: PlaylistViewHolder, position: Int) { - getItem(position)?.let { holder.bind(it) } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PlaylistViewHolder = - PlaylistViewHolder(parent.inflateWithBinding(R.layout.item_playlist), popupMenuListener) - - fun getItemByPosition(position: Int): PlaylistEntity? = getItem(position) - - class PlaylistItemComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: PlaylistEntity, newItem: PlaylistEntity): Boolean = oldItem.playlistId == newItem.playlistId - override fun areContentsTheSame(oldItem: PlaylistEntity, newItem: PlaylistEntity): Boolean = oldItem.name == newItem.name - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/SearchSuggestionAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/SearchSuggestionAdapter.kt deleted file mode 100644 index 17279472b..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/SearchSuggestionAdapter.kt +++ /dev/null @@ -1,28 +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.SuggestionViewHolder - -class SearchSuggestionAdapter( - private val fillQuery: (query: String) -> Unit, -) : RecyclerView.Adapter() { - private var dataSet = emptyList() - - fun setDataSet(dataSet: List) { - this.dataSet = dataSet - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SuggestionViewHolder = - SuggestionViewHolder(parent.inflateWithBinding(R.layout.item_suggestion), fillQuery) - - override fun onBindViewHolder(holder: SuggestionViewHolder, position: Int) = - holder.bind(dataSet[position]) - - override fun getItemCount(): Int = dataSet.size - - fun getQueryByPosition(position: Int): String = dataSet[position] -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/SongsAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/SongsAdapter.kt deleted file mode 100644 index 4ac6cc77a..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/SongsAdapter.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.zionhuang.music.ui.adapters - -import android.view.ViewGroup -import androidx.lifecycle.LiveData -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.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.HEADER_ITEM_ID -import com.zionhuang.music.constants.Constants.TYPE_HEADER -import com.zionhuang.music.constants.Constants.TYPE_ITEM -import com.zionhuang.music.constants.MediaConstants.STATE_DOWNLOADING -import com.zionhuang.music.constants.ORDER_ARTIST -import com.zionhuang.music.constants.ORDER_CREATE_DATE -import com.zionhuang.music.constants.ORDER_NAME -import com.zionhuang.music.models.base.IMutableSortInfo -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.inflateWithBinding -import com.zionhuang.music.models.DownloadProgress -import com.zionhuang.music.ui.listeners.SongPopupMenuListener -import com.zionhuang.music.ui.viewholders.SongHeaderViewHolder -import com.zionhuang.music.ui.viewholders.SongViewHolder -import me.zhanghai.android.fastscroll.PopupTextProvider -import java.text.DateFormat - -class SongsAdapter : PagingDataAdapter(SongItemComparator()), PopupTextProvider { - var popupMenuListener: SongPopupMenuListener? = null - var sortInfo: IMutableSortInfo? = null - var downloadInfo: LiveData>? = null - var tracker: SelectionTracker? = null - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is SongViewHolder -> getItem(position)?.let { song -> - holder.bind(song, tracker?.isSelected(song.id)) - if (song.downloadState == STATE_DOWNLOADING) { - downloadInfo?.value?.get(song.id)?.let { info -> - holder.setProgress(info, false) - } - } - } - is SongHeaderViewHolder -> holder.bind(itemCount - 1) - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List) { - when (holder) { - is SongViewHolder -> { - if (payloads.isEmpty()) { - onBindViewHolder(holder, position) - } else when (val payload = payloads[0]) { - SELECTION_CHANGED_MARKER -> holder.onSelectionChanged( - tracker?.isSelected( - holder.binding.song?.id - ) - ) - is Song -> holder.bind(payload) - is DownloadProgress -> holder.setProgress(payload) - } - } - is SongHeaderViewHolder -> holder.bind(itemCount - 1) - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = - when (viewType) { - TYPE_HEADER -> SongHeaderViewHolder(parent.inflateWithBinding(R.layout.item_song_header), sortInfo!!) - TYPE_ITEM -> SongViewHolder(parent.inflateWithBinding(R.layout.item_song), popupMenuListener) - else -> throw IllegalArgumentException("Unexpected view type.") - } - - fun getItemByPosition(position: Int): Song? = getItem(position) - - fun setProgress(id: String, progress: DownloadProgress) { - snapshot().indexOfFirst { it?.id == id }.takeIf { it != -1 }?.let { - notifyItemChanged(it, progress) - } - } - - override fun getItemViewType(position: Int): Int = - if (getItem(position)?.id == HEADER_ITEM_ID) TYPE_HEADER else TYPE_ITEM - - private val dateFormat = DateFormat.getDateInstance() - - override fun getPopupText(position: Int): String = - if (getItemViewType(position) == TYPE_HEADER) "#" - else getItem(position)?.let { - when (sortInfo!!.type) { - ORDER_CREATE_DATE -> dateFormat.format(it.createDate) - ORDER_NAME -> it.title[0].toString() - ORDER_ARTIST -> it.artistName - else -> it.title[0].toString() - } - } ?: "" - - class SongItemComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Song, newItem: Song): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean = oldItem == newItem - override fun getChangePayload(oldItem: Song, newItem: Song): Song = newItem - } -} \ 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 new file mode 100644 index 000000000..382bef0a0 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/adapters/YouTubeItemAdapter.kt @@ -0,0 +1,95 @@ +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 new file mode 100644 index 000000000..3d6c15475 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/adapters/YouTubeItemPagingAdapter.kt @@ -0,0 +1,114 @@ +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/base/SongBaseAdapter.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/base/SongBaseAdapter.kt deleted file mode 100644 index 6c5639675..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/base/SongBaseAdapter.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.zionhuang.music.ui.adapters.base - -import android.view.ViewGroup -import androidx.lifecycle.LiveData -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.constants.* -import com.zionhuang.music.constants.Constants.HEADER_ITEM_ID -import com.zionhuang.music.models.base.IMutableSortInfo -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.inflateWithBinding -import com.zionhuang.music.models.DownloadProgress -import com.zionhuang.music.ui.listeners.SongPopupMenuListener -import com.zionhuang.music.ui.viewholders.SongHeaderViewHolder -import com.zionhuang.music.ui.viewholders.SongViewHolder -import me.zhanghai.android.fastscroll.PopupTextProvider -import java.text.DateFormat - -class SongsBaseAdapter : PagingDataAdapter(SongItemComparator()), PopupTextProvider { - var popupMenuListener: SongPopupMenuListener? = null - var sortInfo: IMutableSortInfo? = null - var downloadInfo: LiveData>? = null - var tracker: SelectionTracker? = null - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is SongViewHolder -> getItem(position)?.let { song -> - holder.bind(song, tracker?.isSelected(song.id)) - if (song.downloadState == MediaConstants.STATE_DOWNLOADING) { - downloadInfo?.value?.get(song.id) - ?.let { info -> holder.setProgress(info, false) } - } - } - is SongHeaderViewHolder -> holder.bind(itemCount - 1) - } - } - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - payloads: List - ) { - when (holder) { - is SongViewHolder -> { - if (payloads.isEmpty()) { - onBindViewHolder(holder, position) - } else when (val payload = payloads[0]) { - SelectionTracker.SELECTION_CHANGED_MARKER -> holder.onSelectionChanged( - tracker?.isSelected( - holder.binding.song?.id - ) - ) - is Song -> holder.bind(payload) - is DownloadProgress -> holder.setProgress(payload) - } - } - is SongHeaderViewHolder -> holder.bind(itemCount - 1) - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = - when (viewType) { - Constants.TYPE_HEADER -> SongHeaderViewHolder(parent.inflateWithBinding(R.layout.item_song_header), sortInfo!!) - Constants.TYPE_ITEM -> SongViewHolder(parent.inflateWithBinding(R.layout.item_song), popupMenuListener) - else -> throw IllegalArgumentException("Unexpected view type.") - } - - fun getItemByPosition(position: Int): Song? = getItem(position) - - fun setProgress(id: String, progress: DownloadProgress) { - snapshot().indexOfFirst { it?.id == id }.takeIf { it != -1 }?.let { - notifyItemChanged(it, progress) - } - } - - override fun getItemViewType(position: Int): Int = - if (getItem(position)?.id == HEADER_ITEM_ID) Constants.TYPE_HEADER else Constants.TYPE_ITEM - - private val dateFormat = DateFormat.getDateInstance() - - override fun getPopupText(position: Int): String = - if (getItemViewType(position) == Constants.TYPE_HEADER) "#" - else getItem(position)?.let { - when (sortInfo!!.type) { - ORDER_CREATE_DATE -> dateFormat.format(it.createDate) - ORDER_NAME -> it.title[0].toString() - ORDER_ARTIST -> it.artistName - else -> it.title[0].toString() - } - } ?: "" - - class SongItemComparator : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Song, newItem: Song): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean = oldItem == newItem - override fun getChangePayload(oldItem: Song, newItem: Song): Song = newItem - } -} \ 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 new file mode 100644 index 000000000..54cc0438d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/adapters/selection/LocalItemDetailsLookup.kt @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..bcf502c57 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/adapters/selection/LocalItemKeyProvider.kt @@ -0,0 +1,16 @@ +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/SongItemDetailsLookup.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/selection/SongItemDetailsLookup.kt deleted file mode 100644 index eebbe4e5e..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/selection/SongItemDetailsLookup.kt +++ /dev/null @@ -1,15 +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.SongViewHolder - -class SongItemDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup() { - override fun getItemDetails(e: MotionEvent): ItemDetails? { - val view = recyclerView.findChildViewUnder(e.x, e.y) - return view?.let { v -> - (recyclerView.getChildViewHolder(v) as? SongViewHolder)?.itemDetails - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/adapters/selection/SongItemKeyProvider.kt b/app/src/main/java/com/zionhuang/music/ui/adapters/selection/SongItemKeyProvider.kt deleted file mode 100644 index 3268d53a4..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/adapters/selection/SongItemKeyProvider.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.zionhuang.music.ui.adapters.selection - -import androidx.recyclerview.selection.ItemKeyProvider -import com.zionhuang.music.ui.adapters.SongsAdapter - -class SongItemKeyProvider( - private val adapter: SongsAdapter -) : ItemKeyProvider(SCOPE_CACHED) { - override fun getKey(position: Int): String? = - if (position == 0) null else adapter.snapshot()[position]?.id - - override fun getPosition(key: String): Int = - adapter.snapshot().items.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 new file mode 100644 index 000000000..fb6447fc6 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/adapters/selection/YouTubeItemDetailsLookup.kt @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..bfdd08476 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/adapters/selection/YouTubeItemKeyProvider.kt @@ -0,0 +1,11 @@ +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 index 321bbe2d6..49c804bf0 100644 --- a/app/src/main/java/com/zionhuang/music/ui/bindings/Bindings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/bindings/Bindings.kt @@ -1,21 +1,23 @@ package com.zionhuang.music.ui.bindings -import android.net.Uri +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 com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.ArtworkType -import com.zionhuang.music.constants.MediaConstants.TYPE_RECTANGLE -import com.zionhuang.music.constants.MediaConstants.TYPE_SQUARE -import com.zionhuang.music.extensions.* -import com.zionhuang.music.repos.SongRepository +import coil.load +import coil.size.Scale +import coil.size.Size +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) { @@ -23,20 +25,6 @@ fun setEnabled(view: View, enabled: Boolean) { view.alpha = if (enabled) 1f else 0.5f } -@BindingAdapter("artworkType") -fun setArtworkType(view: ImageView, @ArtworkType source: Int) { - view.scaleType = when (source) { - TYPE_SQUARE -> ImageView.ScaleType.CENTER_CROP - TYPE_RECTANGLE -> ImageView.ScaleType.FIT_CENTER - else -> throw IllegalArgumentException("Unknown artwork type.") - } -} - -@BindingAdapter("duration") -fun setDuration(view: TextView, duration: Int) { - view.text = makeTimeString(duration.toLong()) -} - @BindingAdapter("duration") fun setDuration(view: TextView, duration: Long) { view.text = makeTimeString(duration) @@ -64,33 +52,59 @@ fun setRepeatMode(view: RepeatButton, @RepeatMode state: Int) { view.setState(state) } -@BindingAdapter("artworkId") -fun setArtworkId(view: ImageView, id: String) { - view.load(SongRepository.getSongArtworkFile(id)) { - placeholder(R.drawable.ic_music_note) - roundCorner(view.context.resources.getDimensionPixelSize(R.dimen.song_cover_radius)) - } -} - -@BindingAdapter("artworkUri") -fun setArtworkUri(view: ImageView, uri: Uri) { - view.load(uri) { - placeholder(R.drawable.ic_music_note) - roundCorner(view.context.resources.getDimensionPixelSize(R.dimen.song_cover_radius)) - } -} - -@BindingAdapter("srcUrl", "circleCrop", "fullResolution", requireAll = false) -fun setUrl( +@BindingAdapter("srcUrl", "cornerRadius", "circleCrop", "placeholder", "thumbnailWidth", "thumbnailHeight", "originalSize", requireAll = false) +fun setImageUrl( view: ImageView, - url: String? = null, - circleCrop: Boolean = false, - fullResolution: Boolean = false, + url: String?, + cornerRadius: Float?, + circleCrop: Boolean?, + placeholder: Drawable?, + thumbnailWidth: Float?, + thumbnailHeight: Float?, + originalSize: Boolean?, ) { - url?.let { - view.load(it) { - if (circleCrop) circle() - if (fullResolution) fullResolution() + 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) + } + if (originalSize == true) { + size(Size.ORIGINAL) } } } + +@BindingAdapter("thumbnails", "cornerRadius", "circleCrop", "placeholder", "thumbnailWidth", "thumbnailHeight", "originalSize", requireAll = false) +fun setThumbnails( + view: ImageView, + thumbnails: List?, + cornerRadius: Float?, + circleCrop: Boolean?, + placeholder: Drawable?, + thumbnailWidth: Float?, + thumbnailHeight: Float?, + originalSize: Boolean?, +) = setImageUrl(view, thumbnails?.lastOrNull()?.url, cornerRadius, circleCrop, placeholder, thumbnailWidth, thumbnailHeight, originalSize) + +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/fragments/AlbumsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/AlbumsFragment.kt new file mode 100644 index 000000000..2228c1a2d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/AlbumsFragment.kt @@ -0,0 +1,93 @@ +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 index 387148bcf..0b73a7e26 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/ArtistsFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/ArtistsFragment.kt @@ -5,76 +5,96 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View -import androidx.core.view.doOnPreDraw +import androidx.core.view.MenuProvider import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.FragmentNavigatorExtras 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.MaterialElevationScale -import com.google.android.material.transition.MaterialFadeThrough +import com.google.android.material.transition.MaterialSharedAxis +import com.zionhuang.innertube.models.BrowseEndpoint.Companion.artistBrowseEndpoint import com.zionhuang.music.R -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding +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.ArtistsAdapter -import com.zionhuang.music.ui.fragments.base.BindingFragment -import com.zionhuang.music.viewmodels.ArtistsViewModel +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.ArtistsFragmentDirections.actionArtistsFragmentToArtistSongsFragment +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 : BindingFragment() { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) - +class ArtistsFragment : RecyclerViewFragment(), MenuProvider { private val songsViewModel by activityViewModels() - private val artistsViewModel by viewModels() - private val artistsAdapter = ArtistsAdapter() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - exitTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) + private val menuListener = ArtistMenuListener(this) + override val adapter = LocalItemAdapter().apply { + artistMenuListener = menuListener } + private var tracker: SelectionTracker? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - postponeEnterTransition() - view.doOnPreDraw { startPostponedEnterTransition() } - - artistsAdapter.popupMenuListener = artistsViewModel.popupMenuListener - + super.onViewCreated(view, savedInstanceState) binding.recyclerView.apply { layoutManager = LinearLayoutManager(requireContext()) - adapter = artistsAdapter - addOnClickListener { position, view -> - exitTransition = MaterialElevationScale(false).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - reenterTransition = MaterialElevationScale(true).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - val transitionName = getString(R.string.artist_songs_transition_name) - val extras = FragmentNavigatorExtras(view to transitionName) - val directions = ArtistsFragmentDirections.actionArtistsFragmentToArtistSongsFragment(artistsAdapter.getItemByPosition(position)!!.id!!) - findNavController().navigate(directions, extras) + 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(actionArtistsFragmentToArtistSongsFragment(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 { - artistsAdapter.submitData(it) + adapter.submitList(it) } } - } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_settings -> findNavController().navigate(SettingsFragmentDirections.openSettingsFragment()) - } - return true + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.settings, menu) + override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.search_and_settings, menu) + menu.findItem(R.id.action_search).actionView = null } - companion object { - const val TAG = "ArtistsFragment" + 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 index 96755d902..f839bb22c 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/BottomControlsFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/BottomControlsFragment.kt @@ -2,6 +2,7 @@ package com.zionhuang.music.ui.fragments import android.content.Intent import android.os.Bundle +import android.support.v4.media.MediaMetadataCompat.METADATA_KEY_MEDIA_ID import android.support.v4.media.session.PlaybackStateCompat.STATE_NONE import android.support.v4.media.session.PlaybackStateCompat.STATE_STOPPED import android.view.LayoutInflater @@ -13,24 +14,16 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED -import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN +import com.google.android.material.bottomsheet.BottomSheetBehavior.* import com.zionhuang.music.constants.MediaSessionConstants.ACTION_ADD_TO_LIBRARY import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIKE import com.zionhuang.music.databinding.BottomControlsSheetBinding import com.zionhuang.music.ui.activities.MainActivity import com.zionhuang.music.ui.widgets.BottomSheetListener import com.zionhuang.music.ui.widgets.MediaWidgetsController -import com.zionhuang.music.ui.widgets.PlayPauseBehavior import com.zionhuang.music.viewmodels.PlaybackViewModel -import com.zionhuang.music.youtube.NewPipeYouTubeHelper.videoIdToUrl class BottomControlsFragment : Fragment(), BottomSheetListener, MotionLayout.TransitionListener { - companion object { - private const val TAG = "BottomControlsFragment" - } - private lateinit var binding: BottomControlsSheetBinding private val viewModel by activityViewModels() private lateinit var mediaWidgetsController: MediaWidgetsController @@ -51,7 +44,6 @@ class BottomControlsFragment : Fragment(), BottomSheetListener, MotionLayout.Tra private fun setupUI() { binding.motionLayout.background = mainActivity.binding.bottomNav.background - viewModel.setPlayerView(binding.player) // Marquee binding.btmSongTitle.isSelected = true binding.btmSongArtist.isSelected = true @@ -62,21 +54,23 @@ class BottomControlsFragment : Fragment(), BottomSheetListener, MotionLayout.Tra mainActivity.setBottomSheetListener(this) viewModel.playbackState.observe(viewLifecycleOwner) { playbackState -> - if (playbackState.state != STATE_NONE && playbackState.state != STATE_STOPPED && !viewModel.expandOnPlay) { - mainActivity.showBottomSheet() + if (playbackState.state != STATE_NONE && playbackState.state != STATE_STOPPED) { + if (mainActivity.bottomSheetBehavior.state == STATE_HIDDEN) { + mainActivity.bottomSheetBehavior.state = STATE_COLLAPSED + } } } binding.bottomBar.setOnClickListener { - mainActivity.expandBottomSheet() + mainActivity.bottomSheetBehavior.state = STATE_EXPANDED } binding.btnHide.setOnClickListener { - mainActivity.showBottomSheet(true) + mainActivity.bottomSheetBehavior.state = STATE_COLLAPSED } binding.btnQueue.setOnClickListener { - mainActivity.showBottomSheet(true) + mainActivity.bottomSheetBehavior.state = STATE_COLLAPSED findNavController().navigate(QueueFragmentDirections.openQueueFragment()) } @@ -89,16 +83,11 @@ class BottomControlsFragment : Fragment(), BottomSheetListener, MotionLayout.Tra } binding.btnShare.setOnClickListener { - viewModel.mediaData.value?.id?.let { id -> - startActivity(Intent(Intent.ACTION_VIEW, videoIdToUrl(id)?.toUri())) + viewModel.mediaMetadata.value?.getString(METADATA_KEY_MEDIA_ID)?.let { id -> + startActivity(Intent(Intent.ACTION_VIEW, "https://music.youtube.com/watch?v=$id".toUri())) } } - with(PlayPauseBehavior(requireContext())) { - binding.btnBtmPlayPause.setBehavior(this) - binding.btnPlayPause.setBehavior(this) - } - mediaWidgetsController = MediaWidgetsController(requireContext(), binding.progressBar, binding.slider, binding.positionText) } @@ -115,7 +104,7 @@ class BottomControlsFragment : Fragment(), BottomSheetListener, MotionLayout.Tra } override fun onStateChanged(bottomSheet: View, newState: Int) { - binding.progressBar.isVisible = newState == BottomSheetBehavior.STATE_COLLAPSED + binding.progressBar.isVisible = newState == STATE_COLLAPSED if (newState == STATE_HIDDEN) { viewModel.transportControls?.stop() } diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/ExploreFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/ExploreFragment.kt deleted file mode 100644 index 53f267267..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/ExploreFragment.kt +++ /dev/null @@ -1,35 +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 androidx.navigation.fragment.findNavController -import com.google.android.material.transition.MaterialFadeThrough -import com.zionhuang.music.R -import com.zionhuang.music.databinding.FragmentExploreBinding -import com.zionhuang.music.ui.fragments.base.BindingFragment - -class ExploreFragment : BindingFragment() { - override fun getViewBinding() = FragmentExploreBinding.inflate(layoutInflater) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - exitTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - inflater.inflate(R.menu.search_and_settings, menu) - menu.findItem(R.id.action_search).actionView = null - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_search -> findNavController().navigate(R.id.action_explorationFragment_to_searchSuggestionFragment) - R.id.action_settings -> findNavController().navigate(SettingsFragmentDirections.openSettingsFragment()) - } - return true - } -} \ 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 new file mode 100644 index 000000000..b5cd5a96c --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/HomeFragment.kt @@ -0,0 +1,63 @@ +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 new file mode 100644 index 000000000..69a6095ad --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/LocalSearchFragment.kt @@ -0,0 +1,166 @@ +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.logd +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( + 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 index a45e4f419..3fbccf855 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/MenuBottomSheetDialogFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/MenuBottomSheetDialogFragment.kt @@ -5,14 +5,20 @@ import android.view.* import androidx.annotation.MenuRes import androidx.core.os.bundleOf import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.navigation.NavigationView +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 @@ -26,12 +32,15 @@ class MenuBottomSheetDialogFragment : BottomSheetDialogFragment() { onMenuItemClicked = requireArguments().getSerializable(KEY_MENU_LISTENER) as? MenuItemClickListener } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = - inflater.inflate(R.layout.menu_bottom_sheet_dialog, container, false) + 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) - view.findViewById(R.id.navigation_view).apply { + binding.navigationView.background = binding.dragHandle.background + binding.navigationView.apply { inflateMenu(menuResId) menuModifier?.invoke(menu) setNavigationItemSelectedListener { @@ -61,5 +70,33 @@ class MenuBottomSheetDialogFragment : BottomSheetDialogFragment() { 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/PlaylistsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/PlaylistsFragment.kt index 2f14524f1..0249301d2 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/PlaylistsFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/PlaylistsFragment.kt @@ -5,83 +5,102 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View -import androidx.core.view.doOnPreDraw +import androidx.core.view.MenuProvider import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.navigation.findNavController -import androidx.navigation.fragment.FragmentNavigatorExtras 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.MaterialElevationScale -import com.google.android.material.transition.MaterialFadeThrough +import com.google.android.material.transition.MaterialSharedAxis +import com.zionhuang.innertube.models.BrowseEndpoint.Companion.playlistBrowseEndpoint import com.zionhuang.music.R -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding +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.PlaylistsAdapter -import com.zionhuang.music.ui.fragments.base.BindingFragment +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.PlaylistsFragmentDirections.actionPlaylistsFragmentToPlaylistSongsFragment +import com.zionhuang.music.ui.fragments.base.RecyclerViewFragment import com.zionhuang.music.ui.fragments.dialogs.CreatePlaylistDialog -import com.zionhuang.music.viewmodels.PlaylistsViewModel +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 : BindingFragment() { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) - +class PlaylistsFragment : RecyclerViewFragment(), MenuProvider { private val songsViewModel by activityViewModels() - private val playlistsViewModel by viewModels() - private val playlistsAdapter = PlaylistsAdapter() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - exitTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) + private val menuListener = PlaylistMenuListener(this) + override val adapter = LocalItemAdapter().apply { + playlistMenuListener = menuListener } + private var tracker: SelectionTracker? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - postponeEnterTransition() - view.doOnPreDraw { startPostponedEnterTransition() } - - playlistsAdapter.popupMenuListener = playlistsViewModel.popupMenuListener - + super.onViewCreated(view, savedInstanceState) (requireActivity() as MainActivity).fab.setOnClickListener { CreatePlaylistDialog().show(childFragmentManager, null) } binding.recyclerView.apply { layoutManager = LinearLayoutManager(requireContext()) - adapter = playlistsAdapter - addOnClickListener { position, view -> - exitTransition = MaterialElevationScale(false).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - reenterTransition = MaterialElevationScale(true).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - val transitionName = getString(R.string.playlist_songs_transition_name) - val extras = FragmentNavigatorExtras(view to transitionName) - val directions = PlaylistsFragmentDirections.actionPlaylistsFragmentToPlaylistSongsFragment(playlistsAdapter.getItemByPosition(position)!!.playlistId) - findNavController().navigate(directions, extras) + addFastScroller { useMd2Style() } + addOnClickListener { position, _ -> + (this@PlaylistsFragment.adapter.currentList[position] as? Playlist)?.let { playlist -> + if (playlist.playlist.isYouTubePlaylist) { + NavigationEndpointHandler(this@PlaylistsFragment).handle(playlistBrowseEndpoint("VL" + playlist.id)) + } else { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) + findNavController().navigate(actionPlaylistsFragmentToPlaylistSongsFragment(playlist.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_delete -> menuListener.delete(playlists) + } + true + } + } + lifecycleScope.launch { songsViewModel.allPlaylistsFlow.collectLatest { - playlistsAdapter.submitData(it) + adapter.submitList(it) } } - } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_settings -> findNavController().navigate(SettingsFragmentDirections.openSettingsFragment()) - } - return true + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.settings, menu) + override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.search_and_settings, menu) + menu.findItem(R.id.action_search).actionView = null } - companion object { - const val TAG = "PlaylistsFragment" + 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/QueueFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/QueueFragment.kt index 93d229d61..ad0781bba 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/QueueFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/QueueFragment.kt @@ -13,19 +13,16 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.transition.MaterialFadeThrough import com.zionhuang.music.R -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding import com.zionhuang.music.extensions.addOnClickListener import com.zionhuang.music.extensions.moveQueueItem import com.zionhuang.music.extensions.seekToQueueItem import com.zionhuang.music.ui.adapters.MediaQueueAdapter -import com.zionhuang.music.ui.fragments.base.BindingFragment +import com.zionhuang.music.ui.fragments.base.RecyclerViewFragment import com.zionhuang.music.viewmodels.PlaybackViewModel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -class QueueFragment : BindingFragment() { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) - +class QueueFragment : RecyclerViewFragment() { private val viewModel by activityViewModels() private val dragEventManager = DragEventManager() @@ -57,16 +54,16 @@ class QueueFragment : BindingFragment() { override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { val from = viewHolder.absoluteAdapterPosition val to = target.absoluteAdapterPosition - queueAdapter.moveItem(from, to) + adapter.moveItem(from, to) return true } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - viewModel.mediaController.value?.removeQueueItem(queueAdapter.getItem(viewHolder.absoluteAdapterPosition).description) + viewModel.mediaController.value?.removeQueueItem(adapter.getItem(viewHolder.absoluteAdapterPosition).description) } }) - private val queueAdapter: MediaQueueAdapter = MediaQueueAdapter(itemTouchHelper) + override val adapter: MediaQueueAdapter = MediaQueueAdapter(itemTouchHelper) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -75,12 +72,12 @@ class QueueFragment : BindingFragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) binding.recyclerView.apply { layoutManager = LinearLayoutManager(requireContext()) - adapter = queueAdapter itemTouchHelper.attachToRecyclerView(this) addOnClickListener { pos, _ -> - queueAdapter.getItem(pos).description.mediaId?.let { + this@QueueFragment.adapter.getItem(pos).description.mediaId?.let { viewModel.mediaController.value?.seekToQueueItem(it) } } @@ -88,7 +85,7 @@ class QueueFragment : BindingFragment() { lifecycleScope.launch { viewModel.queueData.asFlow().collectLatest { - queueAdapter.submitData(it.items) + adapter.submitData(it.items) } } 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 index 90eab2a25..9fc844c1a 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/SettingsFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/SettingsFragment.kt @@ -2,32 +2,31 @@ package com.zionhuang.music.ui.fragments import android.content.Intent import android.content.Intent.ACTION_VIEW +import android.media.audiofx.AudioEffect.* import android.os.Bundle import android.view.View +import android.widget.Toast +import android.widget.Toast.LENGTH_SHORT +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode import androidx.core.net.toUri import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController -import androidx.preference.ListPreference -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreferenceCompat +import androidx.preference.* import com.google.android.material.color.DynamicColors import com.google.android.material.transition.MaterialFadeThrough import com.zionhuang.music.R import com.zionhuang.music.constants.Constants.APP_URL -import com.zionhuang.music.constants.Constants.NEWPIPE_EXTRACTOR_URL +import com.zionhuang.music.extensions.preferenceLiveData +import com.zionhuang.music.playback.MediaSessionConnection import com.zionhuang.music.update.UpdateInfo.* import com.zionhuang.music.viewmodels.UpdateViewModel -import com.zionhuang.music.youtube.InfoCache -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.localization.ContentCountry -import org.schabi.newpipe.extractor.localization.Localization -import java.util.* class SettingsFragment : PreferenceFragmentCompat() { private val viewModel by activityViewModels() + private val activityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {} + private lateinit var checkForUpdatePreference: Preference private lateinit var updatePreference: Preference @@ -53,25 +52,43 @@ class SettingsFragment : PreferenceFragmentCompat() { true } - val systemDefault = getString(R.string.default_localization_key) - findPreference(getString(R.string.pref_content_language))?.setOnPreferenceChangeListener { _, newValue -> - if (newValue !is String) return@setOnPreferenceChangeListener false - NewPipe.setPreferredLocalization(if (newValue == systemDefault) Localization.fromLocale(Locale.getDefault()) else Localization.fromLocalizationCode(newValue)) - InfoCache.clearCache() + findPreference(getString(R.string.pref_content_language))?.setOnPreferenceChangeListener { preference, newValue -> + if ((preference as ListPreference).value == newValue) return@setOnPreferenceChangeListener false + Toast.makeText(requireContext(), R.string.toast_restart_to_take_effect, LENGTH_SHORT).show() true } - findPreference(getString(R.string.pref_content_country))?.setOnPreferenceChangeListener { _, newValue -> - if (newValue !is String) return@setOnPreferenceChangeListener false - NewPipe.setPreferredContentCountry(ContentCountry(if (newValue == systemDefault) Locale.getDefault().country else newValue)) - InfoCache.clearCache() + findPreference(getString(R.string.pref_content_country))?.setOnPreferenceChangeListener { preference, newValue -> + if ((preference as ListPreference).value == newValue) return@setOnPreferenceChangeListener false + Toast.makeText(requireContext(), R.string.toast_restart_to_take_effect, LENGTH_SHORT).show() true } - findPreference(getString(R.string.pref_app_version))?.setOnPreferenceClickListener { - startActivity(Intent(ACTION_VIEW, APP_URL.toUri())) + + findPreference(getString(R.string.pref_proxy_enabled))?.setOnPreferenceChangeListener { preference, newValue -> + if ((preference as SwitchPreferenceCompat).isChecked == newValue) return@setOnPreferenceChangeListener false + Toast.makeText(requireContext(), R.string.toast_restart_to_take_effect, LENGTH_SHORT).show() true } - findPreference(getString(R.string.pref_newpipe_version))?.setOnPreferenceClickListener { - startActivity(Intent(ACTION_VIEW, NEWPIPE_EXTRACTOR_URL.toUri())) + findPreference(getString(R.string.pref_proxy_url))?.setOnPreferenceChangeListener { preference, newValue -> + if ((preference as EditTextPreference).text == newValue) return@setOnPreferenceChangeListener false + Toast.makeText(requireContext(), R.string.toast_restart_to_take_effect, LENGTH_SHORT).show() + true + } + + 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 + } + } + + findPreference(getString(R.string.pref_app_version))?.setOnPreferenceClickListener { + startActivity(Intent(ACTION_VIEW, APP_URL.toUri())) true } @@ -97,6 +114,11 @@ class SettingsFragment : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + val proxyUrlPreference = findPreference(getString(R.string.pref_proxy_url))!! + requireContext().preferenceLiveData(R.string.pref_proxy_enabled, false).observe(viewLifecycleOwner) { + proxyUrlPreference.isEnabled = it + } viewModel.updateInfo.observe(viewLifecycleOwner) { info -> checkForUpdatePreference.isVisible = info !is UpdateAvailable updatePreference.isVisible = info is UpdateAvailable @@ -113,8 +135,4 @@ class SettingsFragment : PreferenceFragmentCompat() { viewModel.checkForUpdate() } - - companion object { - private const val TAG = "SettingsFragment" - } } \ 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 index 11f56c0cc..adc211697 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/SongsFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/SongsFragment.kt @@ -1,158 +1,110 @@ package com.zionhuang.music.ui.fragments -import android.app.SearchManager import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View -import android.widget.EditText -import androidx.appcompat.widget.SearchView -import androidx.core.content.getSystemService -import androidx.core.os.bundleOf -import androidx.core.view.doOnPreDraw +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.MaterialFadeThrough import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_QUEUE_DATA -import com.zionhuang.music.constants.MediaConstants.QUEUE_ALL_SONG -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding +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.getQueryTextChangeFlow -import com.zionhuang.music.extensions.resolveColor -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.ui.adapters.SongsAdapter -import com.zionhuang.music.ui.adapters.selection.SongItemDetailsLookup -import com.zionhuang.music.ui.adapters.selection.SongItemKeyProvider -import com.zionhuang.music.ui.fragments.base.BindingFragment +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.flow.debounce import kotlinx.coroutines.launch -import kotlin.time.DurationUnit -import kotlin.time.ExperimentalTime -import kotlin.time.toDuration - -class SongsFragment : BindingFragment() { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) +class SongsFragment : RecyclerViewFragment(), MenuProvider { private val playbackViewModel by activityViewModels() private val songsViewModel by activityViewModels() - private val songsAdapter = SongsAdapter() - private var tracker: SelectionTracker? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - exitTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) + private val menuListener = SongMenuListener(this) + override val adapter = LocalItemAdapter().apply { + songMenuListener = menuListener } + private var tracker: SelectionTracker? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - postponeEnterTransition() - view.doOnPreDraw { startPostponedEnterTransition() } - - songsAdapter.apply { - popupMenuListener = songsViewModel.songPopupMenuListener - sortInfo = songsViewModel.sortInfo - downloadInfo = songsViewModel.downloadInfoLiveData - } - + super.onViewCreated(view, savedInstanceState) binding.recyclerView.apply { layoutManager = LinearLayoutManager(requireContext()) - adapter = songsAdapter - addOnClickListener { pos, _ -> - if (pos == 0) return@addOnClickListener - playbackViewModel.playMedia( - requireActivity(), songsAdapter.getItemByPosition(pos)!!.id, bundleOf( - EXTRA_QUEUE_DATA to QueueData(QUEUE_ALL_SONG, sortInfo = songsViewModel.sortInfo.parcelize()) - ) - ) - } + setHasFixedSize(true) addFastScroller { useMd2Style() } } + binding.recyclerView.addOnClickListener { position, _ -> + if (adapter.currentList[position] !is LocalItem) return@addOnClickListener + + playbackViewModel.playQueue(requireActivity(), ListQueue( + items = adapter.currentList.filterIsInstance().map { it.toMediaItem() }, + startIndex = position - 1 + )) + } + adapter.onShuffle = { + playbackViewModel.playQueue(requireActivity(), ListQueue( + items = adapter.currentList.filterIsInstance().shuffled().map { it.toMediaItem() } + )) + } - tracker = SelectionTracker.Builder( - "selectionId", - binding.recyclerView, - SongItemKeyProvider(songsAdapter), - SongItemDetailsLookup(binding.recyclerView), - StorageStrategy.createStringStorage() - ).withSelectionPredicate( - SelectionPredicates.createSelectAnything() - ).build().apply { - songsAdapter.tracker = this - addActionModeObserver(requireActivity(), this, R.menu.song_contextual_action_bar) { item -> - val selectedMap = songsAdapter.snapshot().items - .filter { selection.contains(it.id) } - .associateBy { it.id } - val songs = selection.toList().mapNotNull { selectedMap[it] } - when (item.itemId) { - R.id.action_play_next -> songsViewModel.songPopupMenuListener.playNext(songs, requireContext()) - R.id.action_add_to_queue -> songsViewModel.songPopupMenuListener.addToQueue(songs, requireContext()) - R.id.action_add_to_playlist -> songsViewModel.songPopupMenuListener.addToPlaylist(songs, requireContext()) - R.id.action_download -> songsViewModel.songPopupMenuListener.downloadSongs(selection.toList(), requireContext()) - R.id.action_remove_download -> songsViewModel.songPopupMenuListener.removeDownloads(selection.toList(), requireContext()) - R.id.action_delete -> songsViewModel.songPopupMenuListener.deleteSongs(songs) + 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 } - true } - } lifecycleScope.launch { songsViewModel.allSongsFlow.collectLatest { - songsAdapter.submitData(it) + adapter.submitList(it) } } - songsViewModel.sortInfo.liveData.observe(viewLifecycleOwner) { - songsAdapter.refresh() - } + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + } - songsViewModel.downloadInfoLiveData.observe(viewLifecycleOwner) { map -> - map.forEach { (key, value) -> - songsAdapter.setProgress(key, value) - } - } + 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 onOptionsItemSelected(item: MenuItem): Boolean { + override fun onMenuItemSelected(item: MenuItem): Boolean { when (item.itemId) { - R.id.action_settings -> findNavController().navigate(SettingsFragmentDirections.openSettingsFragment()) + R.id.action_search -> findNavController().navigate(R.id.localSearchFragment) + R.id.action_settings -> findNavController().navigate(R.id.settingsActivity) } return true } - @OptIn(ExperimentalTime::class) - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.search_and_settings, menu) - val searchView = menu.findItem(R.id.action_search).actionView as SearchView - searchView.apply { - findViewById(androidx.appcompat.R.id.search_src_text)?.apply { - setPadding(0, 2, 0, 2) - setTextColor(requireContext().resolveColor(R.attr.colorOnSurface)) - setHintTextColor(requireContext().resolveColor(R.attr.colorOnSurfaceVariant)) - } - setSearchableInfo(requireContext().getSystemService()?.getSearchableInfo(requireActivity().componentName)) - viewLifecycleOwner.lifecycleScope.launch { - getQueryTextChangeFlow() - .debounce(100.toDuration(DurationUnit.MILLISECONDS)) - .collect { e -> - songsViewModel.query = e.query - songsAdapter.refresh() - } - } - } - } - override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) tracker?.onRestoreInstanceState(savedInstanceState) diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/UpdateFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/UpdateFragment.kt index 93eaf1f64..d9a901256 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/UpdateFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/UpdateFragment.kt @@ -1,11 +1,9 @@ package com.zionhuang.music.ui.fragments import android.os.Bundle -import android.os.FileUtils import android.text.Html import android.text.Html.FROM_HTML_MODE_LEGACY import android.text.format.Formatter.formatShortFileSize -import android.util.Log import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels @@ -13,7 +11,6 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope import com.zionhuang.music.R import com.zionhuang.music.databinding.FragmentUpdateBinding -import com.zionhuang.music.extensions.TAG import com.zionhuang.music.models.DownloadProgress import com.zionhuang.music.ui.fragments.base.BindingFragment import com.zionhuang.music.update.UpdateStatus diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubeChannelFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubeChannelFragment.kt deleted file mode 100644 index 7bce41896..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubeChannelFragment.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.graphics.Color -import android.os.Bundle -import android.view.View -import androidx.core.os.bundleOf -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialContainerTransform -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_QUEUE_DATA -import com.zionhuang.music.constants.MediaConstants.QUEUE_YT_CHANNEL -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.extensions.id -import com.zionhuang.music.extensions.requireAppCompatActivity -import com.zionhuang.music.extensions.resolveColor -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.ui.adapters.InfoItemAdapter -import com.zionhuang.music.ui.adapters.LoadStateAdapter -import com.zionhuang.music.ui.fragments.base.BindingFragment -import com.zionhuang.music.utils.bindLoadStateLayout -import com.zionhuang.music.viewmodels.PlaybackViewModel -import com.zionhuang.music.viewmodels.SongsViewModel -import com.zionhuang.music.viewmodels.YouTubeChannelViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.schabi.newpipe.extractor.stream.StreamInfoItem - -class YouTubeChannelFragment : BindingFragment() { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) - - private val args: YouTubeChannelFragmentArgs by navArgs() - private val channelId by lazy { args.channelId } - - private val viewModel by viewModels() - private val songsViewModel by activityViewModels() - private val playbackViewModel by activityViewModels() - - private val infoItemAdapter = InfoItemAdapter() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - sharedElementEnterTransition = MaterialContainerTransform().apply { - drawingViewId = R.id.nav_host_fragment - duration = resources.getInteger(R.integer.motion_duration_large).toLong() - scrimColor = Color.TRANSPARENT - setAllContainerColors(requireContext().resolveColor(R.attr.colorSurface)) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - infoItemAdapter.apply { - streamMenuListener = songsViewModel.streamPopupMenuListener - bindLoadStateLayout(binding.layoutLoadState) - } - - binding.recyclerView.apply { - transitionName = getString(R.string.youtube_channel_transition_name) - layoutManager = LinearLayoutManager(requireContext()) - adapter = - infoItemAdapter.withLoadStateFooter(LoadStateAdapter { infoItemAdapter.retry() }) - addOnClickListener { pos, _ -> - val item = infoItemAdapter.getItemByPosition(pos) - if (item is StreamInfoItem) { - playbackViewModel.playMedia( - requireActivity(), item.id, bundleOf( - EXTRA_QUEUE_DATA to QueueData(QUEUE_YT_CHANNEL, channelId) - ) - ) - } - } - } - - lifecycleScope.launch { - val channel = viewModel.getChannelInfo(channelId) - requireAppCompatActivity().supportActionBar?.title = channel.name - viewModel.getChannel(channelId).collectLatest { - infoItemAdapter.submitData(it) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubePlaylistFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubePlaylistFragment.kt deleted file mode 100644 index 9d68b5027..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubePlaylistFragment.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.zionhuang.music.ui.fragments - -import android.graphics.Color -import android.os.Bundle -import android.view.View -import androidx.core.os.bundleOf -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialContainerTransform -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_QUEUE_DATA -import com.zionhuang.music.constants.MediaConstants.QUEUE_YT_PLAYLIST -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.extensions.id -import com.zionhuang.music.extensions.requireAppCompatActivity -import com.zionhuang.music.extensions.resolveColor -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.ui.adapters.InfoItemAdapter -import com.zionhuang.music.ui.adapters.LoadStateAdapter -import com.zionhuang.music.ui.fragments.base.BindingFragment -import com.zionhuang.music.utils.bindLoadStateLayout -import com.zionhuang.music.viewmodels.PlaybackViewModel -import com.zionhuang.music.viewmodels.SongsViewModel -import com.zionhuang.music.viewmodels.YouTubePlaylistViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.schabi.newpipe.extractor.stream.StreamInfoItem - -class YouTubePlaylistFragment : BindingFragment() { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) - - private val args: YouTubePlaylistFragmentArgs by navArgs() - private val playlistId by lazy { args.playlistId } - - private val viewModel by viewModels() - private val songsViewModel by activityViewModels() - private val playbackViewModel by activityViewModels() - - private val infoItemAdapter = InfoItemAdapter() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - sharedElementEnterTransition = MaterialContainerTransform().apply { - drawingViewId = R.id.nav_host_fragment - duration = resources.getInteger(R.integer.motion_duration_large).toLong() - scrimColor = Color.TRANSPARENT - setAllContainerColors(requireContext().resolveColor(R.attr.colorSurface)) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - infoItemAdapter.apply { - streamMenuListener = songsViewModel.streamPopupMenuListener - bindLoadStateLayout(binding.layoutLoadState) - } - - binding.recyclerView.apply { - transitionName = getString(R.string.youtube_playlist_transition_name) - layoutManager = LinearLayoutManager(requireContext()) - adapter = infoItemAdapter.withLoadStateFooter(LoadStateAdapter { infoItemAdapter.retry() }) - addOnClickListener { pos, _ -> - val item = infoItemAdapter.getItemByPosition(pos) - if (item is StreamInfoItem) { - playbackViewModel.playMedia( - requireActivity(), item.id, bundleOf( - EXTRA_QUEUE_DATA to QueueData(QUEUE_YT_PLAYLIST, playlistId) - ) - ) - } - } - } - - lifecycleScope.launch { - val playlist = viewModel.getPlaylistInfo(playlistId) - requireAppCompatActivity().supportActionBar?.title = playlist.name - viewModel.getPlaylist(playlistId).collectLatest { - infoItemAdapter.submitData(it) - } - } - } -} \ 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 index ef43d3c68..c9558dfda 100644 --- 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 @@ -13,7 +13,6 @@ abstract class BindingFragment : Fragment() { abstract fun getViewBinding(): T override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - setHasOptionsMenu(true) 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 new file mode 100644 index 000000000..aeba5d9f2 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/base/NavigationFragment.kt @@ -0,0 +1,30 @@ +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 new file mode 100644 index 000000000..fd33d1294 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/base/PagingRecyclerViewFragment.kt @@ -0,0 +1,65 @@ +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 new file mode 100644 index 000000000..1f159cfaf --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/base/RecyclerViewFragment.kt @@ -0,0 +1,43 @@ +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.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 + +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()) + } + + 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/ChoosePlaylistDialog.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/ChoosePlaylistDialog.kt new file mode 100644 index 000000000..bb487b771 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/dialogs/ChoosePlaylistDialog.kt @@ -0,0 +1,78 @@ +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 index 4e1b5bb28..7df641897 100644 --- 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 @@ -4,17 +4,32 @@ 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.repos.SongRepository +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -class CreatePlaylistDialog : AppCompatDialogFragment() { +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 { @@ -42,11 +57,17 @@ class CreatePlaylistDialog : AppCompatDialogFragment() { } } + @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 { - SongRepository.addPlaylist(PlaylistEntity(name = name)) + SongRepository.insertPlaylist(playlist) + listener?.invoke(playlist) } dismiss() } 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 index 3f14fdeb1..ef4082b28 100644 --- 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 @@ -11,10 +11,7 @@ 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.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* class EditArtistDialog : AppCompatDialogFragment() { private lateinit var binding: DialogSingleTextInputBinding @@ -52,6 +49,7 @@ class EditArtistDialog : AppCompatDialogFragment() { } } + @OptIn(DelicateCoroutinesApi::class) private fun onOK() { if (binding.textInput.editText?.text.isNullOrEmpty()) return val name = binding.textInput.editText?.text.toString() @@ -63,13 +61,7 @@ class EditArtistDialog : AppCompatDialogFragment() { 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)) { _, _ -> - GlobalScope.launch { - SongRepository.mergeArtists(artist.id!!, existedArtist.id!!) - } - dismiss() - } - .setNegativeButton(resources.getString(android.R.string.cancel), null) + .setPositiveButton(resources.getString(android.R.string.ok), null) .show() } } else { 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 index d688d93bf..50eb22c98 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -50,6 +51,7 @@ class EditPlaylistDialog : AppCompatDialogFragment() { } } + @OptIn(DelicateCoroutinesApi::class) private fun onOK() { if (binding.textInput.editText?.text.isNullOrEmpty()) return val name = binding.textInput.editText?.text.toString() 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 index 01cb032c4..3d404e34b 100644 --- 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 @@ -10,16 +10,16 @@ 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.EditSongDialogBinding +import com.zionhuang.music.databinding.DialogEditSongBinding import com.zionhuang.music.db.entities.Song import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.utils.ArtistAutoCompleteAdapter +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch class EditSongDialog : AppCompatDialogFragment() { - private lateinit var binding: EditSongDialogBinding + private lateinit var binding: DialogEditSongBinding private lateinit var song: Song override fun onCreate(savedInstanceState: Bundle?) { @@ -29,7 +29,7 @@ class EditSongDialog : AppCompatDialogFragment() { private fun setupUI() { (binding.songArtist.editText as? AutoCompleteTextView)?.apply { - setAdapter(ArtistAutoCompleteAdapter(requireContext())) +// setAdapter(ArtistAutoCompleteAdapter(requireContext())) } binding.songTitle.editText?.doOnTextChanged { text, _, _, _ -> binding.songTitle.error = if (text.isNullOrEmpty()) getString(R.string.error_song_title_empty) else null @@ -38,13 +38,13 @@ class EditSongDialog : AppCompatDialogFragment() { binding.songArtist.error = if (text.isNullOrEmpty()) getString(R.string.error_song_artist_empty) else null } with(binding) { - songTitle.editText?.setText(song.title) - songArtist.editText?.setText(song.artistName) + songTitle.editText?.setText(song.song.title) + songArtist.editText?.setText(song.artists.joinToString { it.name }) } } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = EditSongDialogBinding.inflate(requireActivity().layoutInflater) + binding = DialogEditSongBinding.inflate(requireActivity().layoutInflater) setupUI() return MaterialAlertDialogBuilder(requireContext(), R.style.Dialog) @@ -61,22 +61,16 @@ class EditSongDialog : AppCompatDialogFragment() { } } + @OptIn(DelicateCoroutinesApi::class) private fun onSave() { if (binding.songTitle.error != null || binding.songArtist.error != null) { return } val title = binding.songTitle.editText?.text.toString() - val artistName = binding.songArtist.editText?.text.toString() + // TODO GlobalScope.launch { - SongRepository.updateSong(song.copy( - title = title, - artistName = artistName - )) + SongRepository.updateSongTitle(song, title) } dismiss() } - - companion object { - const val TAG = "SongDetailsFragment" - } } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/search/YouTubeSearchFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/search/YouTubeSearchFragment.kt deleted file mode 100644 index 72a3af40f..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/search/YouTubeSearchFragment.kt +++ /dev/null @@ -1,176 +0,0 @@ -package com.zionhuang.music.ui.fragments.search - -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.core.os.bundleOf -import androidx.core.view.doOnPreDraw -import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.FragmentNavigatorExtras -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import androidx.paging.LoadState -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialElevationScale -import com.google.android.material.transition.MaterialFadeThrough -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_QUEUE_DATA -import com.zionhuang.music.constants.MediaConstants.EXTRA_SEARCH_FILTER -import com.zionhuang.music.constants.MediaConstants.QUEUE_YT_SEARCH -import com.zionhuang.music.databinding.FragmentSearchBinding -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.extensions.id -import com.zionhuang.music.extensions.requireAppCompatActivity -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.ui.adapters.InfoItemAdapter -import com.zionhuang.music.ui.adapters.LoadStateAdapter -import com.zionhuang.music.ui.fragments.base.BindingFragment -import com.zionhuang.music.utils.bindLoadStateLayout -import com.zionhuang.music.viewmodels.PlaybackViewModel -import com.zionhuang.music.viewmodels.SearchViewModel -import com.zionhuang.music.viewmodels.SongsViewModel -import com.zionhuang.music.youtube.NewPipeYouTubeHelper.extractChannelId -import com.zionhuang.music.youtube.NewPipeYouTubeHelper.extractPlaylistId -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.launch -import org.schabi.newpipe.extractor.InfoItem -import org.schabi.newpipe.extractor.channel.ChannelInfoItem -import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem -import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory.* -import org.schabi.newpipe.extractor.stream.StreamInfoItem - -class YouTubeSearchFragment : BindingFragment() { - override fun getViewBinding() = FragmentSearchBinding.inflate(layoutInflater) - - private val args: YouTubeSearchFragmentArgs by navArgs() - private val query by lazy { args.searchQuery } - - private val viewModel by viewModels() - private val songsViewModel by activityViewModels() - private val playbackViewModel by activityViewModels() - - private val searchResultAdapter = InfoItemAdapter() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - exitTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - postponeEnterTransition() - view.doOnPreDraw { startPostponedEnterTransition() } - - requireAppCompatActivity().supportActionBar?.title = query - - searchResultAdapter.apply { - streamMenuListener = songsViewModel.streamPopupMenuListener - bindLoadStateLayout(binding.layoutLoadState) - } - binding.recyclerView.apply { - setHasFixedSize(true) - layoutManager = LinearLayoutManager(requireContext()) - adapter = searchResultAdapter.withLoadStateFooter(LoadStateAdapter { searchResultAdapter.retry() }) - addOnClickListener { pos, view -> - when (val item: InfoItem = searchResultAdapter.getItemByPosition(pos)!!) { - is StreamInfoItem -> { - playbackViewModel.playMedia( - requireActivity(), item.id, bundleOf( - EXTRA_QUEUE_DATA to QueueData(QUEUE_YT_SEARCH, query, extras = bundleOf( - EXTRA_SEARCH_FILTER to viewModel.searchFilter.value - )) - ) - ) - } - is PlaylistInfoItem -> { - exitTransition = MaterialElevationScale(false).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - reenterTransition = MaterialElevationScale(true).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - val transitionName = getString(R.string.youtube_playlist_transition_name) - val extras = FragmentNavigatorExtras(view to transitionName) - val directions = YouTubeSearchFragmentDirections.actionSearchResultFragmentToYouTubePlaylistFragment(extractPlaylistId(item.url)!!) - findNavController().navigate(directions, extras) - } - is ChannelInfoItem -> { - exitTransition = MaterialElevationScale(false).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - reenterTransition = MaterialElevationScale(true).setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - val transitionName = getString(R.string.youtube_channel_transition_name) - val extras = FragmentNavigatorExtras(view to transitionName) - val directions = YouTubeSearchFragmentDirections.actionSearchResultFragmentToYouTubeChannelFragment(extractChannelId(item.url)!!) - findNavController().navigate(directions, extras) - } - } - } - } - - binding.chipAll.isVisible = false - binding.chipArtists.isVisible = false - binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> - val filter = when (binding.chipGroup.checkedChipId) { - R.id.chip_all -> ALL - R.id.chip_songs -> MUSIC_SONGS - R.id.chip_videos -> MUSIC_VIDEOS - R.id.chip_albums -> MUSIC_ALBUMS - R.id.chip_artists -> MUSIC_ARTISTS - R.id.chip_playlists -> PLAYLISTS - R.id.chip_channels -> CHANNELS - else -> throw IllegalArgumentException("Unexpected filter type.") - } - viewModel.searchFilter.postValue(filter) - } - - viewModel.searchFilter.observe(viewLifecycleOwner) { filter -> - when (filter) { - ALL -> binding.chipAll - MUSIC_SONGS -> binding.chipSongs - MUSIC_VIDEOS -> binding.chipVideos - MUSIC_ALBUMS -> binding.chipAlbums - MUSIC_ARTISTS -> binding.chipArtists - PLAYLISTS -> binding.chipPlaylists - CHANNELS -> binding.chipChannels - else -> null - }?.isChecked = true - - searchResultAdapter.refresh() - } - - lifecycleScope.launch { - // Always showing the first item when switching filters - searchResultAdapter.loadStateFlow - .distinctUntilChangedBy { it.refresh } - .filter { it.refresh is LoadState.NotLoading } - .collectLatest { - binding.recyclerView.scrollToPosition(0) - } - } - - lifecycleScope.launch { - viewModel.search(query).collectLatest { - searchResultAdapter.submitData(it) - } - } - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - inflater.inflate(R.menu.search_icon, menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.action_search) { - NavHostFragment.findNavController(this).navigate(R.id.action_searchResultFragment_to_searchSuggestionFragment) - } - return true - } - - companion object { - private const val TAG = "SearchResultFragment" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/search/YouTubeSuggestionFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/search/YouTubeSuggestionFragment.kt deleted file mode 100644 index 466da43de..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/search/YouTubeSuggestionFragment.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.zionhuang.music.ui.fragments.search - -import android.app.SearchManager -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.View -import android.widget.EditText -import androidx.appcompat.widget.SearchView -import androidx.core.content.getSystemService -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.NavHostFragment -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialFadeThrough -import com.zionhuang.music.R -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding -import com.zionhuang.music.extensions.addOnClickListener -import com.zionhuang.music.extensions.getQueryTextChangeFlow -import com.zionhuang.music.extensions.resolveColor -import com.zionhuang.music.ui.adapters.SearchSuggestionAdapter -import com.zionhuang.music.ui.fragments.base.BindingFragment -import com.zionhuang.music.viewmodels.SuggestionViewModel -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.launch -import kotlin.time.DurationUnit -import kotlin.time.ExperimentalTime -import kotlin.time.toDuration - -class YouTubeSuggestionFragment : BindingFragment() { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) - - private val viewModel by viewModels() - private lateinit var searchView: SearchView - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - exitTransition = MaterialFadeThrough().setDuration(resources.getInteger(R.integer.motion_duration_large).toLong()) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val suggestionAdapter = SearchSuggestionAdapter { query -> - viewModel.fillQuery(query) - } - binding.recyclerView.apply { - setHasFixedSize(true) - layoutManager = LinearLayoutManager(requireContext()) - adapter = suggestionAdapter - addOnClickListener { pos, _ -> - search(suggestionAdapter.getQueryByPosition(pos)) - } - } - viewModel.apply { - onFillQuery.observe(viewLifecycleOwner) { query -> - searchView.setQuery(query, false) - } - query.observe(viewLifecycleOwner) { query -> - viewModel.fetchSuggestions(query) - } - suggestions.observe(viewLifecycleOwner) { dataSet -> - suggestionAdapter.setDataSet(dataSet) - } - } - } - - @OptIn(ExperimentalTime::class) - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - inflater.inflate(R.menu.search_view, menu) - searchView = menu.findItem(R.id.search_view).actionView as SearchView - setupSearchView() - } - - @OptIn(ExperimentalTime::class) - private fun setupSearchView() { - val searchManager: SearchManager = requireContext().getSystemService()!! - searchView.apply { - isIconified = false - findViewById(androidx.appcompat.R.id.search_src_text)?.apply { - setPadding(0, 2, 0, 2) - setTextColor(requireContext().resolveColor(R.attr.colorOnSurface)) - setHintTextColor(requireContext().resolveColor(R.attr.colorOnSurfaceVariant)) - } - setSearchableInfo(searchManager.getSearchableInfo(requireActivity().componentName)) - isSubmitButtonEnabled = false - maxWidth = Int.MAX_VALUE - setOnCloseListener { true } - viewLifecycleOwner.lifecycleScope.launch { - getQueryTextChangeFlow() - .debounce(100.toDuration(DurationUnit.MILLISECONDS)) - .collect { e -> - if (e.isSubmitted) { - search(e.query.orEmpty()) - } else { - viewModel.setQuery(e.query) - } - } - } - setQuery(viewModel.query.value, false) - } - - } - - private fun search(query: String) { - searchView.clearFocus() - val action = YouTubeSuggestionFragmentDirections.actionSuggestionFragmentToSearchResultFragment(query) - NavHostFragment.findNavController(this).navigate(action) - } -} \ 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 index b8383094f..51dfe767c 100644 --- 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 @@ -1,96 +1,93 @@ package com.zionhuang.music.ui.fragments.songs -import android.graphics.Color import android.os.Bundle import android.view.View -import androidx.core.os.bundleOf -import androidx.core.view.doOnPreDraw 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.MaterialContainerTransform +import com.google.android.material.transition.MaterialSharedAxis import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_ARTIST_ID -import com.zionhuang.music.constants.MediaConstants.EXTRA_QUEUE_DATA -import com.zionhuang.music.constants.MediaConstants.QUEUE_ARTIST -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding +import com.zionhuang.music.db.entities.Song import com.zionhuang.music.extensions.addOnClickListener import com.zionhuang.music.extensions.requireAppCompatActivity -import com.zionhuang.music.extensions.resolveColor -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.ui.adapters.SongsAdapter -import com.zionhuang.music.ui.fragments.base.BindingFragment +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 : BindingFragment() { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) - +class ArtistSongsFragment : RecyclerViewFragment() { private val args: ArtistSongsFragmentArgs by navArgs() - private val artistId by lazy { args.artistId } private val playbackViewModel by activityViewModels() private val songsViewModel by activityViewModels() - private val songsAdapter = SongsAdapter() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - sharedElementEnterTransition = MaterialContainerTransform().apply { - drawingViewId = R.id.nav_host_fragment - duration = resources.getInteger(R.integer.motion_duration_large).toLong() - scrimColor = Color.TRANSPARENT - setAllContainerColors(requireContext().resolveColor(R.attr.colorSurface)) - } + private val menuListener = SongMenuListener(this) + override val adapter = LocalItemAdapter().apply { + songMenuListener = menuListener } + private var tracker: SelectionTracker? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - postponeEnterTransition() - view.doOnPreDraw { startPostponedEnterTransition() } + super.onViewCreated(view, savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true).addTarget(R.id.fragment_content) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false).addTarget(R.id.fragment_content) - songsAdapter.apply { - popupMenuListener = songsViewModel.songPopupMenuListener - sortInfo = songsViewModel.sortInfo - downloadInfo = songsViewModel.downloadInfoLiveData + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.addOnClickListener { position, _ -> + if (adapter.currentList[position] !is Song) return@addOnClickListener + playbackViewModel.playQueue(requireActivity(), + ListQueue( + items = adapter.currentList.drop(1).filterIsInstance().map { it.toMediaItem() }, + startIndex = position - 1 + ) + ) } - - binding.recyclerView.apply { - transitionName = getString(R.string.artist_songs_transition_name) - layoutManager = LinearLayoutManager(requireContext()) - adapter = songsAdapter - addOnClickListener { pos, _ -> - if (pos == 0) return@addOnClickListener - playbackViewModel.playMedia( - requireActivity(), songsAdapter.getItemByPosition(pos)!!.id, bundleOf( - EXTRA_QUEUE_DATA to QueueData(QUEUE_ARTIST, sortInfo = songsViewModel.sortInfo.parcelize(), extras = bundleOf( - EXTRA_ARTIST_ID to artistId - )) - ) + adapter.onShuffle = { + playbackViewModel.playQueue(requireActivity(), + ListQueue( + items = adapter.currentList.drop(1).filterIsInstance().shuffled().map { it.toMediaItem() } ) - } + ) } - lifecycleScope.launch { - requireAppCompatActivity().title = songsViewModel.songRepository.getArtistById(artistId)!!.name - songsViewModel.getArtistSongsAsFlow(artistId).collectLatest { - songsAdapter.submitData(it) + 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 + } } - } - - songsViewModel.sortInfo.liveData.observe(viewLifecycleOwner) { - songsAdapter.refresh() - } - songsViewModel.downloadInfoLiveData.observe(viewLifecycleOwner) { map -> - map.forEach { (key, value) -> - songsAdapter.setProgress(key, value) + lifecycleScope.launch { + requireAppCompatActivity().title = SongRepository.getArtistById(args.artistId)!!.name + songsViewModel.getArtistSongsAsFlow(args.artistId).collectLatest { + adapter.submitList(it) } } } - - companion object { - val TAG = "ArtistSongsFragment" - } } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/songs/PlaylistSongsEditFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/songs/PlaylistSongsEditFragment.kt deleted file mode 100644 index bbfcde2f6..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/songs/PlaylistSongsEditFragment.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.zionhuang.music.ui.fragments.songs - -import android.graphics.Canvas -import android.graphics.Color -import android.os.Bundle -import android.view.View -import androidx.core.view.ViewCompat -import androidx.core.view.doOnPreDraw -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.transition.MaterialContainerTransform -import com.zionhuang.music.R -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding -import com.zionhuang.music.extensions.resolveColor -import com.zionhuang.music.ui.adapters.PlaylistSongsEditAdapter -import com.zionhuang.music.ui.fragments.base.BindingFragment -import com.zionhuang.music.viewmodels.PlaylistSongsViewModel -import com.zionhuang.music.viewmodels.SongsViewModel -import kotlinx.coroutines.launch - -class PlaylistSongsEditFragment : BindingFragment() { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) - - private val args: PlaylistSongsEditFragmentArgs by navArgs() - private val playlistId by lazy { args.playlistId } - - private val songsViewModel by activityViewModels() - private val viewModel by viewModels() - private val songsAdapter = PlaylistSongsEditAdapter() - - private val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) { - private val elevation by lazy { requireContext().resources.getDimension(R.dimen.drag_item_elevation) } - - override fun isLongPressDragEnabled(): Boolean = false - - 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) - } - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - val from = viewHolder.absoluteAdapterPosition - val to = target.absoluteAdapterPosition - songsAdapter.moveItem(from, to) - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit - }) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - sharedElementEnterTransition = MaterialContainerTransform().apply { - drawingViewId = R.id.nav_host_fragment - duration = resources.getInteger(R.integer.motion_duration_large).toLong() - scrimColor = Color.TRANSPARENT - setAllContainerColors(requireContext().resolveColor(R.attr.colorSurface)) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - postponeEnterTransition() - view.doOnPreDraw { startPostponedEnterTransition() } - - songsAdapter.apply { - itemTouchHelper = this@PlaylistSongsEditFragment.itemTouchHelper - onProcessMove = { - viewModel.processMove(playlistId, it) - } - } - - binding.recyclerView.apply { - transitionName = getString(R.string.playlist_songs_transition_name) - layoutManager = LinearLayoutManager(requireContext()) - adapter = songsAdapter - itemTouchHelper.attachToRecyclerView(this) - } - - lifecycleScope.launch { - songsAdapter.submitList(songsViewModel.songRepository.getPlaylistSongs(playlistId, songsViewModel.sortInfo).getList()) - } - } - - override fun onDestroy() { - songsAdapter.processMove() - super.onDestroy() - } -} \ 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 index 14e39c6d0..8b8d950d0 100644 --- 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 @@ -1,56 +1,64 @@ package com.zionhuang.music.ui.fragments.songs import android.graphics.Canvas -import android.graphics.Color import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem +import android.view.MotionEvent import android.view.View -import androidx.core.os.bundleOf import androidx.core.view.ViewCompat -import androidx.core.view.doOnPreDraw import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController 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.MaterialContainerTransform +import com.google.android.material.transition.MaterialSharedAxis import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.EXTRA_PLAYLIST_ID -import com.zionhuang.music.constants.MediaConstants.EXTRA_QUEUE_DATA -import com.zionhuang.music.constants.MediaConstants.QUEUE_PLAYLIST -import com.zionhuang.music.databinding.LayoutRecyclerviewBinding +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.resolveColor -import com.zionhuang.music.models.QueueData -import com.zionhuang.music.ui.adapters.PlaylistSongsAdapter -import com.zionhuang.music.ui.fragments.base.BindingFragment +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.PlaylistSongsViewModel import com.zionhuang.music.viewmodels.SongsViewModel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -class PlaylistSongsFragment : BindingFragment() { - override fun getViewBinding() = LayoutRecyclerviewBinding.inflate(layoutInflater) - +class PlaylistSongsFragment : 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 viewModel by viewModels() - private val songsAdapter = PlaylistSongsAdapter() + private val menuListener = SongMenuListener(this) + override val adapter = DraggableLocalItemAdapter().apply { + songMenuListener = menuListener + isDraggable = true + } + private var tracker: SelectionTracker? = null - private val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { + 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) { @@ -61,82 +69,87 @@ class PlaylistSongsFragment : BindingFragment() { override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { super.clearView(recyclerView, viewHolder) ViewCompat.setElevation(viewHolder.itemView, 0f) - songsAdapter.processMove() + 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 - val to = target.absoluteAdapterPosition - songsAdapter.moveItem(from, to) + 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) { - viewModel.removeFromPlaylist(playlistId, songsAdapter.getItemByPosition(viewHolder.absoluteAdapterPosition)!!.idInPlaylist!!) + val position = viewHolder.absoluteAdapterPosition - 1 + lifecycleScope.launch { + SongRepository.removeSongFromPlaylist(playlistId, position) + } } }) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - sharedElementEnterTransition = MaterialContainerTransform().apply { - drawingViewId = R.id.nav_host_fragment - duration = resources.getInteger(R.integer.motion_duration_large).toLong() - scrimColor = Color.TRANSPARENT - setAllContainerColors(requireContext().resolveColor(R.attr.colorSurface)) - } - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - postponeEnterTransition() - view.doOnPreDraw { startPostponedEnterTransition() } - - songsAdapter.apply { - popupMenuListener = songsViewModel.songPopupMenuListener - downloadInfo = songsViewModel.downloadInfoLiveData - itemTouchHelper = this@PlaylistSongsFragment.itemTouchHelper - onProcessMove = { - viewModel.processMove(playlistId, it) - } - } + 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 { - transitionName = getString(R.string.playlist_songs_transition_name) layoutManager = LinearLayoutManager(requireContext()) - adapter = songsAdapter + setHasFixedSize(true) itemTouchHelper.attachToRecyclerView(this) - addOnClickListener { pos, _ -> - playbackViewModel.playMedia( - requireActivity(), songsAdapter.getItemByPosition(pos)!!.id, bundleOf( - EXTRA_QUEUE_DATA to QueueData(QUEUE_PLAYLIST, sortInfo = songsViewModel.sortInfo.parcelize(), extras = bundleOf( - EXTRA_PLAYLIST_ID to playlistId - )) - ) - ) - } } + binding.recyclerView.addOnClickListener { position, _ -> + if (adapter.currentList[position] !is LocalItem) return@addOnClickListener - lifecycleScope.launch { - songsViewModel.getPlaylistSongsAsFlow(playlistId).collectLatest { - songsAdapter.submitData(it) - } + playbackViewModel.playQueue(requireActivity(), ListQueue( + items = adapter.currentList.filterIsInstance().map { it.toMediaItem() }, + startIndex = position - 1 + )) } - - songsViewModel.downloadInfoLiveData.observe(viewLifecycleOwner) { map -> - map.forEach { (key, value) -> - songsAdapter.setProgress(key, value) - } + adapter.itemTouchHelper = itemTouchHelper + adapter.onShuffle = { + playbackViewModel.playQueue(requireActivity(), ListQueue( + items = adapter.currentList.filterIsInstance().shuffled().map { it.toMediaItem() } + )) } - } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - inflater.inflate(R.menu.playlist_songs_fragment, menu) - } + 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 + } + } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_edit -> findNavController().navigate(PlaylistSongsFragmentDirections.actionPlaylistSongsFragmentToPlaylistSongsEditFragment(playlistId)) + lifecycleScope.launch { + requireAppCompatActivity().title = SongRepository.getPlaylistById(playlistId).playlist.name + songsViewModel.getPlaylistSongsAsFlow(playlistId).collectLatest { + adapter.submitList(it, animation = false) + } } - return true } } \ 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 new file mode 100644 index 000000000..0c0520896 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/youtube/YouTubeBrowseFragment.kt @@ -0,0 +1,125 @@ +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.getAlbumSongs()?.let { songs -> + MediaSessionConnection.binder?.songPlayer?.playQueue(ListQueue( + items = songs.map { it.toMediaItem() } + )) + } + } + adapter.onShuffleAlbum = { + viewModel.getAlbumSongs()?.let { songs -> + MediaSessionConnection.binder?.songPlayer?.playQueue(ListQueue( + items = songs.shuffled().map { it.toMediaItem() } + )) + } + } + binding.recyclerView.addOnClickListener { position, _ -> + (adapter.getItemAt(position) as? SongItem)?.let { item -> + viewModel.getAlbumSongs()?.let { songs -> + MediaSessionConnection.binder?.songPlayer?.playQueue(ListQueue( + 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 new file mode 100644 index 000000000..93e8fae07 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/youtube/YouTubeSearchFragment.kt @@ -0,0 +1,155 @@ +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(R.id.action_searchResultFragment_to_searchSuggestionFragment) + } + 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 new file mode 100644 index 000000000..0a2a3a637 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/youtube/YouTubeSuggestionFragment.kt @@ -0,0 +1,131 @@ +package com.zionhuang.music.ui.fragments.youtube + +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.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.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.music.R +import com.zionhuang.music.databinding.FragmentYoutubeSuggestionBinding +import com.zionhuang.music.extensions.getTextChangeFlow +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.ui.fragments.youtube.YouTubeSuggestionFragmentDirections.actionSuggestionFragmentToSearchResultFragment +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 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 + } + binding.btnVoice.setOnClickListener { + voiceResultLauncher.launch(Intent(ACTION_RECOGNIZE_SPEECH)) + } + setupSearchView() + showKeyboard() + 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) { + GlobalScope.launch { + SongRepository.insertSearchHistory(query) + } + exitTransition = null + val action = actionSuggestionFragmentToSearchResultFragment(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 new file mode 100644 index 000000000..8751ddbab --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/listeners/AlbumMenuListener.kt @@ -0,0 +1,129 @@ +package com.zionhuang.music.ui.listeners + +import android.content.Context +import android.content.Intent +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.innertube.models.BrowseEndpoint.Companion.artistBrowseEndpoint +import com.zionhuang.music.R +import com.zionhuang.music.constants.MediaConstants.EXTRA_MEDIA_METADATA_ITEMS +import com.zionhuang.music.constants.MediaSessionConstants +import com.zionhuang.music.constants.MediaSessionConstants.COMMAND_PLAY_NEXT +import com.zionhuang.music.db.entities.Album +import com.zionhuang.music.models.toMediaMetadata +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 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(private val fragment: Fragment) : IAlbumMenuListener { + val context: Context + get() = fragment.requireContext() + + val mainActivity: MainActivity + get() = fragment.requireActivity() as MainActivity + + @OptIn(DelicateCoroutinesApi::class) + override fun playNext(albums: List) { + val mainContent = mainActivity.binding.mainContent + GlobalScope.launch { + val songs = albums.flatMap { album -> + SongRepository.getAlbumSongs(album.id) + } + MediaSessionConnection.mediaController?.sendCommand( + COMMAND_PLAY_NEXT, + bundleOf(EXTRA_MEDIA_METADATA_ITEMS to songs.map { it.toMediaMetadata() }.toTypedArray()), + null + ) + Snackbar.make(mainContent, context.resources.getQuantityString(R.plurals.snackbar_album_play_next, albums.size, albums.size), LENGTH_SHORT).show() + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun addToQueue(albums: List) { + val mainContent = mainActivity.binding.mainContent + GlobalScope.launch { + val songs = albums.flatMap { album -> + SongRepository.getAlbumSongs(album.id) + } + MediaSessionConnection.mediaController?.sendCommand( + MediaSessionConstants.COMMAND_ADD_TO_QUEUE, + bundleOf(EXTRA_MEDIA_METADATA_ITEMS to songs.map { it.toMediaMetadata() }.toTypedArray()), + null + ) + Snackbar.make(mainContent, context.resources.getQuantityString(R.plurals.snackbar_album_added_to_queue, albums.size, albums.size), LENGTH_SHORT).show() + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun addToPlaylist(albums: List) { + val mainContent = mainActivity.binding.mainContent + ChoosePlaylistDialog { playlist -> + GlobalScope.launch { + SongRepository.addToPlaylist(playlist, albums) + 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 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 { + albums.forEach { album -> + SongRepository.refetchAlbum(album.album) + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun delete(albums: List) { + GlobalScope.launch { + 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 new file mode 100644 index 000000000..97fe66cbc --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/listeners/ArtistMenuListener.kt @@ -0,0 +1,135 @@ +package com.zionhuang.music.ui.listeners + +import android.content.Context +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 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_ARTIST +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.Artist +import com.zionhuang.music.extensions.show +import com.zionhuang.music.models.sortInfo.SongSortInfoPreference +import com.zionhuang.music.models.toMediaMetadata +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.dialogs.EditArtistDialog +import com.zionhuang.music.ui.fragments.songs.PlaylistSongsFragmentArgs +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 delete(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)) + fun delete(artist: Artist) = delete(listOf(artist)) +} + +class ArtistMenuListener(private val fragment: Fragment) : IArtistMenuListener { + val context: Context + get() = fragment.requireContext() + + val mainActivity: MainActivity + get() = fragment.requireActivity() as MainActivity + + override fun edit(artist: Artist) { + EditArtistDialog().apply { + arguments = bundleOf(EXTRA_ARTIST to artist) + }.show(context) + } + + @OptIn(DelicateCoroutinesApi::class) + override fun playNext(artists: List) { + val mainContent = mainActivity.binding.mainContent + GlobalScope.launch { + val songs = artists.flatMap { artist -> + SongRepository.getArtistSongs(artist.id, SongSortInfoPreference).getList() + } + MediaSessionConnection.mediaController?.sendCommand( + COMMAND_PLAY_NEXT, + bundleOf(EXTRA_MEDIA_METADATA_ITEMS to songs.map { it.toMediaMetadata() }.toTypedArray()), + null + ) + Snackbar.make(mainContent, context.resources.getQuantityString(R.plurals.snackbar_artist_play_next, artists.size, artists.size), LENGTH_SHORT).show() + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun addToQueue(artists: List) { + val mainContent = mainActivity.binding.mainContent + GlobalScope.launch { + val songs = artists.flatMap { artist -> + SongRepository.getArtistSongs(artist.id, SongSortInfoPreference).getList() + } + MediaSessionConnection.mediaController?.sendCommand( + COMMAND_ADD_TO_QUEUE, + bundleOf(EXTRA_MEDIA_METADATA_ITEMS to songs.map { it.toMediaMetadata() }.toTypedArray()), + null + ) + Snackbar.make(mainContent, context.resources.getQuantityString(R.plurals.snackbar_artist_added_to_queue, artists.size, artists.size), LENGTH_SHORT).show() + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun addToPlaylist(artists: List) { + val mainContent = mainActivity.binding.mainContent + ChoosePlaylistDialog { playlist -> + GlobalScope.launch { + SongRepository.addToPlaylist(playlist, artists) + 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 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 { + artists.forEach { artist -> + SongRepository.refetchArtist(artist.artist) + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun delete(artists: List) { + GlobalScope.launch { + SongRepository.deleteArtists(artists.map { it.artist }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/listeners/ArtistPopupMenuListener.kt b/app/src/main/java/com/zionhuang/music/ui/listeners/ArtistPopupMenuListener.kt deleted file mode 100644 index a5a84646c..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/listeners/ArtistPopupMenuListener.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.zionhuang.music.ui.listeners - -import android.content.Context -import com.zionhuang.music.db.entities.ArtistEntity - -interface ArtistPopupMenuListener { - fun editArtist(artist: ArtistEntity, context: Context) - fun deleteArtist(artist: ArtistEntity) -} \ 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 new file mode 100644 index 000000000..338aa0615 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/listeners/PlaylistMenuListener.kt @@ -0,0 +1,206 @@ +package com.zionhuang.music.ui.listeners + +import android.content.Context +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 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.QueueAddEndpoint +import com.zionhuang.innertube.models.QueueAddEndpoint.Companion.INSERT_AFTER_CURRENT_VIDEO +import com.zionhuang.innertube.models.QueueAddEndpoint.Companion.INSERT_AT_END +import com.zionhuang.innertube.models.WatchPlaylistEndpoint +import com.zionhuang.music.R +import com.zionhuang.music.constants.MediaConstants +import com.zionhuang.music.constants.MediaConstants.EXTRA_MEDIA_METADATA_ITEMS +import com.zionhuang.music.constants.MediaSessionConstants +import com.zionhuang.music.constants.MediaSessionConstants.COMMAND_PLAY_NEXT +import com.zionhuang.music.db.entities.Playlist +import com.zionhuang.music.extensions.show +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.MediaSessionConnection +import com.zionhuang.music.playback.queues.ListQueue +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.dialogs.EditPlaylistDialog +import com.zionhuang.music.ui.fragments.songs.PlaylistSongsFragmentArgs +import com.zionhuang.music.utils.NavigationEndpointHandler +import kotlinx.coroutines.* + +interface IPlaylistMenuListener { + fun edit(playlist: Playlist) + fun play(playlists: List) + fun playNext(playlists: List) + fun addToQueue(playlists: List) + fun addToPlaylist(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 refetch(playlist: Playlist) = refetch(listOf(playlist)) + fun delete(playlist: Playlist) = delete(listOf(playlist)) +} + +class PlaylistMenuListener(private val fragment: Fragment) : IPlaylistMenuListener { + val context: Context + get() = fragment.requireContext() + + val mainActivity: MainActivity + get() = fragment.requireActivity() as MainActivity + + override fun edit(playlist: Playlist) { + EditPlaylistDialog().apply { + arguments = bundleOf(MediaConstants.EXTRA_PLAYLIST to playlist.playlist) + }.show(context) + } + + @OptIn(DelicateCoroutinesApi::class) + override fun play(playlists: List) { + GlobalScope.launch { + val songs = playlists.flatMap { playlist -> + if (playlist.playlist.isYouTubePlaylist) { + YouTube.getQueue(playlistId = playlist.id).map { it.toMediaItem() } + } else { + SongRepository.getPlaylistSongs(playlist.id).getList().map { it.toMediaItem() } + } + } + withContext(Dispatchers.Main) { + MediaSessionConnection.binder?.songPlayer?.playQueue(ListQueue( + items = songs + )) + } + } + } + + override fun play(playlist: Playlist) { + if (playlist.playlist.isYouTubePlaylist) { + NavigationEndpointHandler(fragment).handle(WatchPlaylistEndpoint(playlistId = playlist.id)) + } else { + play(listOf(playlist)) + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun playNext(playlists: List) { + val mainContent = mainActivity.binding.mainContent + GlobalScope.launch { + val songs = playlists.flatMap { playlist -> + if (playlist.playlist.isYouTubePlaylist) { + YouTube.getQueue(playlistId = playlist.id).map { it.toMediaMetadata() } + } else { + SongRepository.getPlaylistSongs(playlist.id).getList().map { it.toMediaMetadata() } + } + } + MediaSessionConnection.mediaController?.sendCommand( + COMMAND_PLAY_NEXT, + bundleOf(EXTRA_MEDIA_METADATA_ITEMS to songs.toTypedArray()), + null + ) + Snackbar.make(mainContent, context.resources.getQuantityString(R.plurals.snackbar_playlist_play_next, playlists.size, playlists.size), LENGTH_SHORT).show() + } + } + + override fun playNext(playlist: Playlist) { + val mainContent = mainActivity.binding.mainContent + if (playlist.playlist.isYouTubePlaylist) { + NavigationEndpointHandler(fragment).handle(QueueAddEndpoint( + queueInsertPosition = INSERT_AFTER_CURRENT_VIDEO, + queueTarget = QueueAddEndpoint.QueueTarget( + playlistId = playlist.id + ) + )) + Snackbar.make(mainContent, context.resources.getQuantityString(R.plurals.snackbar_playlist_play_next, 1, 1), LENGTH_SHORT).show() + } else { + playNext(listOf(playlist)) + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun addToQueue(playlists: List) { + val mainContent = mainActivity.binding.mainContent + GlobalScope.launch { + val songs = playlists.flatMap { playlist -> + if (playlist.playlist.isYouTubePlaylist) { + YouTube.getQueue(playlistId = playlist.id).map { it.toMediaMetadata() } + } else { + SongRepository.getPlaylistSongs(playlist.id).getList().map { it.toMediaMetadata() } + } + } + MediaSessionConnection.mediaController?.sendCommand( + MediaSessionConstants.COMMAND_ADD_TO_QUEUE, + bundleOf(EXTRA_MEDIA_METADATA_ITEMS to songs.toTypedArray()), + null + ) + Snackbar.make(mainContent, context.resources.getQuantityString(R.plurals.snackbar_playlist_added_to_queue, playlists.size, playlists.size), LENGTH_SHORT).show() + } + } + + override fun addToQueue(playlist: Playlist) { + val mainContent = mainActivity.binding.mainContent + if (playlist.playlist.isYouTubePlaylist) { + NavigationEndpointHandler(fragment).handle(QueueAddEndpoint( + queueInsertPosition = INSERT_AT_END, + queueTarget = QueueAddEndpoint.QueueTarget( + playlistId = playlist.id + ) + )) + Snackbar.make(mainContent, context.resources.getQuantityString(R.plurals.snackbar_playlist_added_to_queue, 1, 1), LENGTH_SHORT).show() + } else { + addToQueue(listOf(playlist)) + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun addToPlaylist(playlists: List) { + val mainContent = mainActivity.binding.mainContent + ChoosePlaylistDialog { playlist -> + GlobalScope.launch { + SongRepository.addToPlaylist(playlist, playlists) + 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 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 { + playlists.forEach { playlist -> + SongRepository.refetchPlaylist(playlist) + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun delete(playlists: List) { + GlobalScope.launch { + SongRepository.deletePlaylists(playlists.map { it.playlist }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/listeners/PlaylistPopupMenuListener.kt b/app/src/main/java/com/zionhuang/music/ui/listeners/PlaylistPopupMenuListener.kt deleted file mode 100644 index 28f9a21b2..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/listeners/PlaylistPopupMenuListener.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.zionhuang.music.ui.listeners - -import android.content.Context -import com.zionhuang.music.db.entities.PlaylistEntity - -interface PlaylistPopupMenuListener { - fun editPlaylist(playlist: PlaylistEntity, context: Context) - fun deletePlaylist(playlist: PlaylistEntity) -} \ 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 new file mode 100644 index 000000000..8850afb0c --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/listeners/SongMenuListener.kt @@ -0,0 +1,156 @@ +package com.zionhuang.music.ui.listeners + +import android.content.Context +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 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.BrowseEndpoint.Companion.albumBrowseEndpoint +import com.zionhuang.innertube.models.BrowseEndpoint.Companion.artistBrowseEndpoint +import com.zionhuang.music.R +import com.zionhuang.music.constants.MediaConstants.EXTRA_MEDIA_METADATA_ITEMS +import com.zionhuang.music.constants.MediaConstants.EXTRA_SONG +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.Song +import com.zionhuang.music.extensions.show +import com.zionhuang.music.models.toMediaMetadata +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.dialogs.EditSongDialog +import com.zionhuang.music.ui.fragments.songs.PlaylistSongsFragmentArgs +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 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(private val fragment: Fragment) : ISongMenuListener { + val context: Context + get() = fragment.requireContext() + + val mainActivity: MainActivity + get() = fragment.requireActivity() as MainActivity + + override fun editSong(song: Song) { + EditSongDialog().apply { + arguments = bundleOf(EXTRA_SONG to song) + }.show(context) + } + + override fun playNext(songs: List) { + val mainContent = mainActivity.binding.mainContent + MediaSessionConnection.mediaController?.sendCommand( + COMMAND_PLAY_NEXT, + bundleOf(EXTRA_MEDIA_METADATA_ITEMS to songs.map { it.toMediaMetadata() }.toTypedArray()), + null + ) + Snackbar.make(mainContent, context.resources.getQuantityString(R.plurals.snackbar_song_play_next, songs.size, songs.size), LENGTH_SHORT).show() + } + + override fun addToQueue(songs: List) { + val mainContent = mainActivity.binding.mainContent + MediaSessionConnection.mediaController?.sendCommand( + COMMAND_ADD_TO_QUEUE, + bundleOf(EXTRA_MEDIA_METADATA_ITEMS to songs.map { it.toMediaMetadata() }.toTypedArray()), + null + ) + Snackbar.make(mainContent, context.resources.getQuantityString(R.plurals.snackbar_song_added_to_queue, songs.size, songs.size), LENGTH_SHORT).show() + } + + @OptIn(DelicateCoroutinesApi::class) + override fun addToPlaylist(songs: List) { + val mainContent = mainActivity.binding.mainContent + ChoosePlaylistDialog { playlist -> + GlobalScope.launch { + SongRepository.addToPlaylist(playlist, songs) + 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) + } + + @OptIn(DelicateCoroutinesApi::class) + override fun download(songs: List) { + GlobalScope.launch { + 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()) { + NavigationEndpointHandler(fragment).handle(artistBrowseEndpoint(song.artists[0].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 { + 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/SongPopupMenuListener.kt b/app/src/main/java/com/zionhuang/music/ui/listeners/SongPopupMenuListener.kt deleted file mode 100644 index ddc3afebd..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/listeners/SongPopupMenuListener.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.zionhuang.music.ui.listeners - -import android.content.Context -import com.zionhuang.music.db.entities.Song - -interface SongPopupMenuListener { - fun editSong(song: Song, context: Context) - fun playNext(songs: List, context: Context) - fun addToQueue(songs: List, context: Context) - fun addToPlaylist(songs: List, context: Context) - fun downloadSongs(songIds: List, context: Context) - fun removeDownloads(songIds: List, context: Context) - fun deleteSongs(songs: List) - - fun playNext(song: Song, context: Context) = playNext(listOf(song), context) - fun addToQueue(song: Song, context: Context) = addToQueue(listOf(song), context) - fun addToPlaylist(song: Song, context: Context) = addToPlaylist(listOf(song), context) - fun downloadSong(songId: String, context: Context) = downloadSongs(listOf(songId), context) - fun removeDownload(songId: String, context: Context) = removeDownloads(listOf(songId), context) - fun deleteSongs(song: Song) = deleteSongs(listOf(song)) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/listeners/StreamPopupMenuListener.kt b/app/src/main/java/com/zionhuang/music/ui/listeners/StreamPopupMenuListener.kt deleted file mode 100644 index ee8c1b4a4..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/listeners/StreamPopupMenuListener.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.zionhuang.music.ui.listeners - -import android.content.Context -import org.schabi.newpipe.extractor.stream.StreamInfoItem - -interface StreamPopupMenuListener { - fun addToLibrary(songs: List) - fun playNext(songs: List) - fun addToQueue(songs: List) - fun addToPlaylist(songs: List, context: Context) - fun download(songs: List, context: Context) - - fun addToLibrary(song: StreamInfoItem) = addToLibrary(listOf(song)) - fun playNext(song: StreamInfoItem) = playNext(listOf(song)) - fun addToQueue(song: StreamInfoItem) = addToQueue(listOf(song)) - fun addToPlaylist(song: StreamInfoItem, context: Context) = addToPlaylist(listOf(song), context) - fun download(song: StreamInfoItem, context: Context) = download(listOf(song), context) -} \ 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 new file mode 100644 index 000000000..332b7ba56 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/listeners/YTItemBatchMenuListener.kt @@ -0,0 +1,125 @@ +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.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.* + +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 { + 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) { + MediaSessionConnection.binder?.songPlayer?.playNext(items.flatMap { item -> + when (item) { + is SongItem -> listOf(item.toMediaItem()) + is AlbumItem -> withContext(Dispatchers.IO) { + YouTube.browse(BrowseEndpoint(browseId = "VL" + item.playlistId)).items.filterIsInstance().map { it.toMediaItem() } + // consider refetch by [YouTube.getQueue] if needed + } + is PlaylistItem -> withContext(Dispatchers.IO) { + YouTube.getQueue(playlistId = item.id).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) { + MediaSessionConnection.binder?.songPlayer?.addToQueue(items.flatMap { item -> + when (item) { + is SongItem -> listOf(item.toMediaItem()) + is AlbumItem -> withContext(Dispatchers.IO) { + YouTube.browse(BrowseEndpoint(browseId = "VL" + item.playlistId)).items.filterIsInstance().map { it.toMediaItem() } + // consider refetch by [YouTube.getQueue] if needed + } + is PlaylistItem -> withContext(Dispatchers.IO) { + YouTube.getQueue(playlistId = item.id).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 { + 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 { + 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/recycler/RecyclerPaddingDecoration.java b/app/src/main/java/com/zionhuang/music/ui/recycler/RecyclerPaddingDecoration.java deleted file mode 100644 index 81ace0a82..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/recycler/RecyclerPaddingDecoration.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.zionhuang.music.ui.recycler; - -import android.graphics.Rect; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -public class RecyclerPaddingDecoration extends RecyclerView.ItemDecoration { - private Rect mPadding = new Rect(); - - public RecyclerPaddingDecoration(Rect padding) { - mPadding.set(padding); - } - - public RecyclerPaddingDecoration(int left, int top, int right, int bottom) { - mPadding.set(left, top, right, bottom); - } - - @Override - public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { - int left = mPadding.left; - int right = mPadding.right; - int top = 0; - int bottom = 0; - - int adapterPosition = parent.getChildAdapterPosition(view); - if (adapterPosition == 0) { - top = mPadding.top; - } - if (adapterPosition == state.getItemCount() - 1) { - bottom = mPadding.bottom; - } - - outRect.set(left, top, right, bottom); - } -} diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/ArtistViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/ArtistViewHolder.kt deleted file mode 100644 index b79e066da..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/ArtistViewHolder.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.databinding.ItemArtistBinding -import com.zionhuang.music.db.entities.ArtistEntity -import com.zionhuang.music.extensions.context -import com.zionhuang.music.extensions.show -import com.zionhuang.music.ui.fragments.MenuBottomSheetDialogFragment -import com.zionhuang.music.ui.listeners.ArtistPopupMenuListener - -class ArtistViewHolder( - val binding: ItemArtistBinding, - private val popupMenuListener: ArtistPopupMenuListener?, -) : RecyclerView.ViewHolder(binding.root) { - fun bind(artist: ArtistEntity) { - binding.artist = artist - binding.btnMoreAction.setOnClickListener { - MenuBottomSheetDialogFragment - .newInstance(R.menu.artist) - .setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_edit -> popupMenuListener?.editArtist(artist, binding.context) - R.id.action_delete -> popupMenuListener?.deleteArtist(artist) - } - } - .show(binding.context) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/ChannelHeaderViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/ChannelHeaderViewHolder.kt deleted file mode 100644 index 454e8dba0..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/ChannelHeaderViewHolder.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.databinding.ItemChannelHeaderBinding -import com.zionhuang.music.extensions.context - -class ChannelHeaderViewHolder(val binding: ItemChannelHeaderBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(songsCount: Int) { - binding.songsCount.text = binding.context.resources.getQuantityString(R.plurals.songs_count, songsCount, songsCount) - binding.executePendingBindings() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/DraggableSongViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/DraggableSongViewHolder.kt deleted file mode 100644 index 889408249..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/DraggableSongViewHolder.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import androidx.core.view.isVisible -import com.zionhuang.music.databinding.ItemSongBinding - -class DraggableSongViewHolder( - override val binding: ItemSongBinding -) : SongViewHolder(binding, null) { - init { - binding.btnMoreAction.isVisible = false - binding.dragHandle.isVisible = true - } -} \ 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 new file mode 100644 index 000000000..0e3113af9 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/viewholders/LocalItemViewHolder.kt @@ -0,0 +1,447 @@ +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.context +import com.zionhuang.music.extensions.fadeIn +import com.zionhuang.music.extensions.fadeOut +import com.zionhuang.music.extensions.show +import com.zionhuang.music.models.DownloadProgress +import com.zionhuang.music.models.sortInfo.* +import com.zionhuang.music.ui.fragments.MenuBottomSheetDialogFragment +import com.zionhuang.music.ui.listeners.IAlbumMenuListener +import com.zionhuang.music.ui.listeners.IArtistMenuListener +import com.zionhuang.music.ui.listeners.IPlaylistMenuListener +import com.zionhuang.music.ui.listeners.ISongMenuListener +import com.zionhuang.music.utils.joinByBullet +import com.zionhuang.music.utils.makeTimeString + +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_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_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() + } + + fun setProgress(progress: DownloadProgress, animate: Boolean = true) { + binding.progressBar.run { + max = progress.totalBytes + setProgress(progress.currentBytes, animate) + } + } + + override fun onSelectionChanged(isSelected: Boolean) { + if (isSelected) binding.selectedIndicator.fadeIn(binding.context.resources.getInteger(R.integer.motion_duration_small).toLong()) + else binding.selectedIndicator.fadeOut(binding.context.resources.getInteger(R.integer.motion_duration_small).toLong()) + } +} + +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 + } + + 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) + R.id.action_delete -> menuListener?.delete(artist) + } + } + .show(binding.context) + } + binding.selectedIndicator.isVisible = isSelected + } + + override fun onSelectionChanged(isSelected: Boolean) { + if (isSelected) binding.selectedIndicator.fadeIn(binding.context.resources.getInteger(R.integer.motion_duration_small).toLong()) + else binding.selectedIndicator.fadeOut(binding.context.resources.getInteger(R.integer.motion_duration_small).toLong()) + } +} + +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 + } + + 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 + } + + override fun onSelectionChanged(isSelected: Boolean) { + if (isSelected) binding.selectedIndicator.fadeIn(binding.context.resources.getInteger(R.integer.motion_duration_small).toLong()) + else binding.selectedIndicator.fadeOut(binding.context.resources.getInteger(R.integer.motion_duration_small).toLong()) + } +} + +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_share).isVisible = playlist.playlist.isYouTubePlaylist + findItem(R.id.action_refetch).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_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) { + if (isSelected) binding.selectedIndicator.fadeIn(binding.context.resources.getInteger(R.integer.motion_duration_small).toLong()) + else binding.selectedIndicator.fadeOut(binding.context.resources.getInteger(R.integer.motion_duration_small).toLong()) + } +} + +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 + 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 + })?.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 + }) + 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/PlaylistViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/PlaylistViewHolder.kt deleted file mode 100644 index 71f2abb7c..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/PlaylistViewHolder.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.databinding.ItemPlaylistBinding -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.extensions.context -import com.zionhuang.music.extensions.show -import com.zionhuang.music.ui.fragments.MenuBottomSheetDialogFragment -import com.zionhuang.music.ui.listeners.PlaylistPopupMenuListener - -class PlaylistViewHolder( - val binding: ItemPlaylistBinding, - private val popupMenuListener: PlaylistPopupMenuListener?, -) : RecyclerView.ViewHolder(binding.root) { - fun bind(playlist: PlaylistEntity) { - binding.playlist = playlist - binding.btnMoreAction.setOnClickListener { - MenuBottomSheetDialogFragment - .newInstance(R.menu.artist) - .setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_edit -> popupMenuListener?.editPlaylist(playlist, binding.context) - R.id.action_delete -> popupMenuListener?.deletePlaylist(playlist) - } - } - .show(binding.context) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchChannelViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchChannelViewHolder.kt deleted file mode 100644 index bfdeaa6d9..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchChannelViewHolder.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import com.zionhuang.music.R -import com.zionhuang.music.databinding.ItemSearchChannelBinding -import com.zionhuang.music.extensions.circle -import com.zionhuang.music.extensions.context -import com.zionhuang.music.extensions.load -import com.zionhuang.music.ui.viewholders.base.SearchViewHolder -import org.schabi.newpipe.extractor.channel.ChannelInfoItem - -class SearchChannelViewHolder(override val binding: ItemSearchChannelBinding) : SearchViewHolder(binding) { - private val context = binding.context - - fun bind(item: ChannelInfoItem) { - binding.root.transitionName = binding.context.resources.getString( - R.string.youtube_channel_item_transition_name, - item.url - ) - binding.channelTitle.text = item.name - binding.subscribers.text = context.resources.getQuantityString(R.plurals.subscribers, item.subscriberCount.toInt(), item.subscriberCount.toInt()) - binding.streams.text = context.resources.getQuantityString(R.plurals.videos, item.streamCount.toInt(), item.streamCount.toInt()) - binding.thumbnail.load(item.thumbnailUrl) { - placeholder(R.drawable.ic_music_note) - circle() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchPlaylistViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchPlaylistViewHolder.kt deleted file mode 100644 index fcc349852..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchPlaylistViewHolder.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import com.zionhuang.music.R -import com.zionhuang.music.databinding.ItemSearchPlaylistBinding -import com.zionhuang.music.extensions.context -import com.zionhuang.music.extensions.load -import com.zionhuang.music.extensions.roundCorner -import com.zionhuang.music.ui.viewholders.base.SearchViewHolder -import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem - -class SearchPlaylistViewHolder(override val binding: ItemSearchPlaylistBinding) : SearchViewHolder(binding) { - fun bind(item: PlaylistInfoItem) { - binding.root.transitionName = binding.context.resources.getString(R.string.youtube_playlist_item_transition_name, item.url) - binding.playlistTitle.text = item.name - binding.uploader.text = item.uploaderName - if (item.streamCount > 0) { - binding.streams.text = item.streamCount.toString() - } else { - binding.thumbnail.alpha = 1f - } - binding.thumbnail.load(item.thumbnailUrl) { - placeholder(R.drawable.ic_music_note) - roundCorner(binding.thumbnail.context.resources.getDimensionPixelSize(R.dimen.song_cover_radius)) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchStreamViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchStreamViewHolder.kt deleted file mode 100644 index 8280846d6..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchStreamViewHolder.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import androidx.core.view.isVisible -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import com.zionhuang.music.R -import com.zionhuang.music.databinding.ItemSearchStreamBinding -import com.zionhuang.music.extensions.* -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.fragments.MenuBottomSheetDialogFragment -import com.zionhuang.music.ui.listeners.StreamPopupMenuListener -import com.zionhuang.music.ui.viewholders.base.SearchViewHolder -import com.zionhuang.music.utils.makeTimeString -import org.schabi.newpipe.extractor.stream.StreamInfoItem - -class SearchStreamViewHolder( - override val binding: ItemSearchStreamBinding, - private val listener: StreamPopupMenuListener?, -) : SearchViewHolder(binding) { - private var inLibraryLiveData: LiveData? = null - private var inLibraryObserver = Observer { - binding.addedToLibrary.isVisible = it - } - - fun bind(item: StreamInfoItem) { - binding.songTitle.text = item.name - binding.duration.text = makeTimeString(item.duration) - binding.songArtist.text = item.uploaderName - binding.publishDate.text = item.textualUploadDate - if (item.textualUploadDate.isNullOrEmpty()) { - binding.publishBullet.isVisible = false - } - binding.thumbnail.load(item.thumbnailUrl) { - placeholder(R.drawable.ic_music_note) - roundCorner(binding.thumbnail.context.resources.getDimensionPixelSize(R.dimen.song_cover_radius)) - } - removeListener() - SongRepository.hasSong(item.id).liveData.apply { - inLibraryLiveData = this - observeForever(inLibraryObserver) - } - setupMenu(item) - } - - private fun setupMenu(item: StreamInfoItem) { - binding.btnMoreAction.setOnClickListener { - MenuBottomSheetDialogFragment - .newInstance(R.menu.stream) - .setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_add_to_library -> listener?.addToLibrary(item) - R.id.action_play_next -> listener?.playNext(item) - R.id.action_add_to_queue -> listener?.addToQueue(item) - R.id.action_add_to_playlist -> listener?.addToPlaylist(item, binding.context) - R.id.action_download -> listener?.download(item, binding.context) - } - } - .show(binding.context) - } - } - - private fun removeListener() { - binding.addedToLibrary.isVisible = false - inLibraryLiveData?.removeObserver(inLibraryObserver) - inLibraryLiveData = null - } - - override fun onDetach() { - super.onDetach() - removeListener() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/SongHeaderViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/SongHeaderViewHolder.kt deleted file mode 100644 index 0e29b7cbe..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/SongHeaderViewHolder.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import android.widget.PopupMenu -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.constants.ORDER_ARTIST -import com.zionhuang.music.constants.ORDER_CREATE_DATE -import com.zionhuang.music.constants.ORDER_NAME -import com.zionhuang.music.databinding.ItemSongHeaderBinding -import com.zionhuang.music.models.base.IMutableSortInfo - -class SongHeaderViewHolder( - val binding: ItemSongHeaderBinding, - private val sortInfo: IMutableSortInfo, -) : RecyclerView.ViewHolder(binding.root) { - init { - binding.sortMenu.setOnClickListener { view -> - PopupMenu(view.context, view).apply { - inflate(R.menu.sort_song) - setOnMenuItemClickListener { - sortInfo.type = when (it.itemId) { - R.id.sort_by_create_date -> ORDER_CREATE_DATE - R.id.sort_by_name -> ORDER_NAME - R.id.sort_by_artist -> ORDER_ARTIST - else -> throw IllegalArgumentException("Unexpected sort type.") - } - updateSortName(sortInfo.type) - true - } - menu.findItem(when (sortInfo.type) { - ORDER_CREATE_DATE -> R.id.sort_by_create_date - ORDER_NAME -> R.id.sort_by_name - ORDER_ARTIST -> R.id.sort_by_artist - else -> throw IllegalArgumentException("Unexpected sort type.") - })?.isChecked = true - show() - } - } - binding.sortMenu.setOnLongClickListener { - sortInfo.toggleIsDescending() - updateSortOrderIcon(sortInfo.isDescending) - true - } - updateSortName(sortInfo.type) - updateSortOrderIcon(sortInfo.isDescending, false) - } - - fun bind(songsCount: Int) { - binding.songsCount = songsCount - } - - private fun updateSortName(sortType: Int) { - binding.sortName.setText(when (sortType) { - ORDER_CREATE_DATE -> R.string.sort_by_create_date - ORDER_NAME -> R.string.sort_by_name - ORDER_ARTIST -> R.string.sort_by_artist - else -> throw IllegalArgumentException("Unexpected sort type.") - }) - } - - private fun updateSortOrderIcon(sortDescending: Boolean, animate: Boolean = true) { - if (sortDescending) { - binding.sortOrderIcon.animateToDown(animate) - } else { - binding.sortOrderIcon.animateToUp(animate) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/SongViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/SongViewHolder.kt deleted file mode 100644 index cea754d17..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/SongViewHolder.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.R -import com.zionhuang.music.constants.MediaConstants.STATE_DOWNLOADED -import com.zionhuang.music.constants.MediaConstants.STATE_NOT_DOWNLOADED -import com.zionhuang.music.databinding.ItemSongBinding -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.context -import com.zionhuang.music.extensions.show -import com.zionhuang.music.models.DownloadProgress -import com.zionhuang.music.ui.fragments.MenuBottomSheetDialogFragment -import com.zionhuang.music.ui.listeners.SongPopupMenuListener - -open class SongViewHolder( - open val binding: ItemSongBinding, - private val popupMenuListener: SongPopupMenuListener?, -) : RecyclerView.ViewHolder(binding.root) { - val itemDetails: ItemDetailsLookup.ItemDetails - get() = object : ItemDetailsLookup.ItemDetails() { - override fun getPosition(): Int = absoluteAdapterPosition - override fun getSelectionKey(): String? = binding.song?.id - } - - fun bind(song: Song, selected: Boolean? = false) { - binding.song = song - binding.btnMoreAction.setOnClickListener { - MenuBottomSheetDialogFragment - .newInstance(R.menu.song) - .setMenuModifier { - findItem(R.id.action_download).isVisible = song.downloadState == STATE_NOT_DOWNLOADED - findItem(R.id.action_remove_download).isVisible = song.downloadState == STATE_DOWNLOADED - } - .setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_edit -> popupMenuListener?.editSong(song, binding.context) - R.id.action_play_next -> popupMenuListener?.playNext(song, binding.context) - R.id.action_add_to_queue -> popupMenuListener?.addToQueue(song, binding.context) - R.id.action_add_to_playlist -> popupMenuListener?.addToPlaylist(song, binding.context) - R.id.action_download -> popupMenuListener?.downloadSong(song.id, binding.context) - R.id.action_remove_download -> popupMenuListener?.removeDownload(song.id, binding.context) - R.id.action_delete -> popupMenuListener?.deleteSongs(song) - } - } - .show(binding.context) - } - binding.isSelected = selected == true - binding.executePendingBindings() - } - - fun setProgress(progress: DownloadProgress, animate: Boolean = true) { - binding.progressBar.run { - max = progress.totalBytes - setProgress(progress.currentBytes, animate) - } - } - - fun onSelectionChanged(selected: Boolean?) { - binding.isSelected = selected == true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/SuggestionViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/SuggestionViewHolder.kt deleted file mode 100644 index ecc633f58..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/SuggestionViewHolder.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zionhuang.music.ui.viewholders - -import androidx.recyclerview.widget.RecyclerView -import com.zionhuang.music.databinding.ItemSuggestionBinding - -class SuggestionViewHolder(private val binding: ItemSuggestionBinding, val fillQuery: (query: String) -> Unit) : RecyclerView.ViewHolder(binding.root) { - fun bind(query: String) { - binding.query = query - binding.executePendingBindings() - binding.fillTextButton.setOnClickListener { fillQuery(binding.query!!) } - } -} \ 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 new file mode 100644 index 000000000..acd8bed4f --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/viewholders/YouTubeViewHolder.kt @@ -0,0 +1,257 @@ +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.fadeIn +import com.zionhuang.music.extensions.fadeOut +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 + header.moreNavigationEndpoint?.let { endpoint -> + binding.root.isClickable = true + binding.root.setOnClickListener { + navigationEndpointHandler.handle(endpoint) + } + } + } +} + +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) { + if (isSelected) binding.selectedIndicator.fadeIn(binding.context.resources.getInteger(R.integer.motion_duration_small).toLong()) + else binding.selectedIndicator.fadeOut(binding.context.resources.getInteger(R.integer.motion_duration_small).toLong()) + } +} + +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.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 new file mode 100644 index 000000000..31408db97 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/viewholders/base/BindingViewHolder.kt @@ -0,0 +1,11 @@ +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/viewholders/base/LifecycleViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/base/LifecycleViewHolder.kt deleted file mode 100644 index 86a15c60d..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/base/LifecycleViewHolder.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.zionhuang.music.ui.viewholders.base - -import android.view.View -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import androidx.recyclerview.widget.RecyclerView - -open class LifecycleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), LifecycleOwner { - @Suppress("LeakingThis") - private val lifecycleRegistry = LifecycleRegistry(this) - - init { - lifecycleRegistry.currentState = Lifecycle.State.INITIALIZED - lifecycleRegistry.currentState = Lifecycle.State.CREATED - itemView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View?) { - onAttach() - } - - override fun onViewDetachedFromWindow(v: View?) { - onDetach() - } - }) - } - - override fun getLifecycle(): Lifecycle = lifecycleRegistry - - open fun onAttach() { - lifecycleRegistry.currentState = Lifecycle.State.STARTED - lifecycleRegistry.currentState = Lifecycle.State.RESUMED - } - - open fun onDetach() { - lifecycleRegistry.currentState = Lifecycle.State.STARTED - lifecycleRegistry.currentState = Lifecycle.State.CREATED - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/base/SearchViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/base/SearchViewHolder.kt deleted file mode 100644 index 51cc47d1e..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/base/SearchViewHolder.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.zionhuang.music.ui.viewholders.base - -import androidx.databinding.ViewDataBinding - -open class SearchViewHolder(open val binding: ViewDataBinding) : LifecycleViewHolder(binding.root) \ 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 new file mode 100644 index 000000000..f16c88674 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/widgets/ExpandableTextView.kt @@ -0,0 +1,37 @@ +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/MediaWidgetsController.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/MediaWidgetsController.kt index 9cbb226dc..c57cb065b 100644 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/MediaWidgetsController.kt +++ b/app/src/main/java/com/zionhuang/music/ui/widgets/MediaWidgetsController.kt @@ -14,10 +14,10 @@ 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, + context: Context, + private val progressBar: ProgressBar, + private val slider: Slider, + private val progressTextView: TextView, ) { private var sliderIsTracking = false private var mediaController: MediaControllerCompat? = null @@ -40,7 +40,7 @@ class MediaWidgetsController( }) slider.addOnChangeListener { _, value, _ -> - progressTextView.text = makeTimeString((value / 1000).toLong()) + progressTextView.text = makeTimeString((value).toLong()) } } @@ -81,7 +81,7 @@ class MediaWidgetsController( if (slider.isEnabled) { slider.value = progress.toFloat().coerceIn(slider.valueFrom, slider.valueTo) } - progressTextView.text = makeTimeString(progress.toLong() / 1000) + progressTextView.text = makeTimeString(progress.toLong()) if (state.state == PlaybackStateCompat.STATE_PLAYING) { val timeToEnd = ((duration - progress) / state.playbackSpeed).toInt() if (timeToEnd > 0) { @@ -119,7 +119,7 @@ class MediaWidgetsController( val animatedValue = animation.animatedValue as Int progressBar.progress = animatedValue slider.value = animatedValue.toFloat().coerceIn(slider.valueFrom, slider.valueTo) - progressTextView.text = makeTimeString((animatedValue / 1000).toLong()) + progressTextView.text = makeTimeString(animatedValue.toLong()) } } } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/widgets/PlayPauseBehavior.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/PlayPauseBehavior.kt deleted file mode 100644 index a882faa19..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/PlayPauseBehavior.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.zionhuang.music.ui.widgets - -import android.content.Context -import android.graphics.drawable.Drawable -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 PlayPauseBehavior(context: Context) { - 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 callbacks = mutableMapOf() - - fun animatePlay(button: PlayPauseButton) { - if (button.tag != STATE_PLAY) { - button.setAvd(playAnimation) - button.tag = STATE_PLAY - } - } - - fun animationPause(button: PlayPauseButton) { - if (button.tag != STATE_PAUSE) { - button.setAvd(pauseAnimation) - button.tag = STATE_PAUSE - } - } - - private fun PlayPauseButton.setAvd(avd: AnimatedVectorDrawableCompat) { - with(avd) { - setImageDrawable(this) - start() - registerAnimationCallback(callbacks[this@setAvd] - ?: AnimationCallback(this@setAvd).also { callbacks[this@setAvd] = it }) - } - } - - inner class AnimationCallback(private val button: PlayPauseButton) : Animatable2Compat.AnimationCallback() { - override fun onAnimationEnd(drawable: Drawable?) { - when (drawable) { - playAnimation -> button.setImageDrawable(pauseDrawable) - pauseAnimation -> button.setImageDrawable(playDrawable) - } - } - } - - 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/PlayPauseButton.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/PlayPauseButton.kt index 7b7cf8898..bc035b4c3 100644 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/PlayPauseButton.kt +++ b/app/src/main/java/com/zionhuang/music/ui/widgets/PlayPauseButton.kt @@ -1,36 +1,70 @@ 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 androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import com.zionhuang.music.R +import com.zionhuang.music.extensions.getAnimatedVectorDrawable class PlayPauseButton : AppCompatImageView { - private lateinit var behavior: PlayPauseBehavior + 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) { - with(TypedValue()) { - context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, this, true) - setBackgroundResource(resourceId) - } + val tintResId = context + .obtainStyledAttributes(attrs, R.styleable.PlayPauseButton) + .getColor(R.styleable.PlayPauseButton_playPauseButtonTint, resources.getColor(R.color.colorInverted, context.theme)) + playAnimation.setTint(tintResId) + pauseAnimation.setTint(tintResId) + playDrawable.setTint(tintResId) + pauseDrawable.setTint(tintResId) + isClickable = true scaleType = ScaleType.CENTER_CROP setImageResource(R.drawable.ic_play) } - fun setBehavior(behavior: PlayPauseBehavior) { - this.behavior = behavior - } - fun animatePlay() { - behavior.animatePlay(this) + if (tag != STATE_PLAY) { + setAvd(playAnimation) + tag = STATE_PLAY + } } fun animationPause() { - behavior.animationPause(this) + 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/SortOrderImageView.kt b/app/src/main/java/com/zionhuang/music/ui/widgets/SortOrderImageView.kt index 315321ec2..ca4fd2512 100644 --- a/app/src/main/java/com/zionhuang/music/ui/widgets/SortOrderImageView.kt +++ b/app/src/main/java/com/zionhuang/music/ui/widgets/SortOrderImageView.kt @@ -12,6 +12,7 @@ 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) @@ -30,7 +31,8 @@ class SortOrderImageView : AppCompatImageView { constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) fun animateToUp(animate: Boolean) { - if (drawable != arrowUpwardDrawable) { + if (state != State.UP) { + state = State.UP if (animate) { setAvd(arrowDownToUpAnimation) } else { @@ -40,7 +42,8 @@ class SortOrderImageView : AppCompatImageView { } fun animateToDown(animate: Boolean) { - if (drawable != arrowDownwardDrawable) { + if (state != State.DOWN) { + state = State.DOWN if (animate) { setAvd(arrowUpToDownAnimation) } else { @@ -56,4 +59,8 @@ class SortOrderImageView : AppCompatImageView { registerAnimationCallback(animationCallback) } } + + enum class State { + UP, DOWN + } } \ 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 index 4aabffa37..8c0b9e4c4 100644 --- a/app/src/main/java/com/zionhuang/music/utils/ActionModeSelectionObserver.kt +++ b/app/src/main/java/com/zionhuang/music/utils/ActionModeSelectionObserver.kt @@ -53,13 +53,12 @@ class ActionModeSelectionObserver( fun SelectionTracker.addActionModeObserver( activity: Activity, - tracker: SelectionTracker, @MenuRes menuRes: Int, onActionItemClicked: (MenuItem) -> Boolean, ) = addObserver( ActionModeSelectionObserver( activity, - tracker, + this, menuRes, onActionItemClicked ) diff --git a/app/src/main/java/com/zionhuang/music/utils/ArtistAutoCompleteAdapter.kt b/app/src/main/java/com/zionhuang/music/utils/ArtistAutoCompleteAdapter.kt deleted file mode 100644 index c6dfaa5ba..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/ArtistAutoCompleteAdapter.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.zionhuang.music.utils - -import android.content.Context -import android.widget.ArrayAdapter -import android.widget.Filter -import com.zionhuang.music.db.entities.ArtistEntity -import com.zionhuang.music.repos.SongRepository -import kotlinx.coroutines.runBlocking - -class ArtistAutoCompleteAdapter(context: Context) : ArrayAdapter(context, android.R.layout.simple_list_item_1) { - private val songRepository = SongRepository - - private val filter = object : Filter() { - override fun performFiltering(constraint: CharSequence?): FilterResults { - val resultsList = runBlocking { - if (constraint.isNullOrEmpty()) { - songRepository.getAllArtists().getList() - } else { - songRepository.searchArtists(constraint.toString()).getList() - } - } - return FilterResults().apply { - values = resultsList - count = resultsList.size - } - } - - @Suppress("UNCHECKED_CAST") - override fun publishResults(constraint: CharSequence?, results: FilterResults) { - clear() - addAll(results.values as List) - notifyDataSetChanged() - } - - override fun convertResultToString(resultValue: Any?): CharSequence = (resultValue as ArtistEntity).name - } - - override fun getFilter(): Filter = filter - - init { - setNotifyOnChange(false) - } - - companion object { - private const val TAG = "AutoCompleteAdapter" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/youtube/InfoCache.kt b/app/src/main/java/com/zionhuang/music/utils/InfoCache.kt similarity index 63% rename from app/src/main/java/com/zionhuang/music/youtube/InfoCache.kt rename to app/src/main/java/com/zionhuang/music/utils/InfoCache.kt index 8d3d5f3d4..400efeaff 100644 --- a/app/src/main/java/com/zionhuang/music/youtube/InfoCache.kt +++ b/app/src/main/java/com/zionhuang/music/utils/InfoCache.kt @@ -1,6 +1,8 @@ -package com.zionhuang.music.youtube +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 @@ -33,10 +35,11 @@ object InfoCache { return data.info } - fun getFromKey(id: String): Any? = - synchronized(LRU_CACHE) { getInfo(keyOf(id)) } + private fun getFromKey(id: String): Any? = synchronized(LRU_CACHE) { + getInfo(keyOf(id)) + } - fun putInfo(id: String, info: Any) { + private fun putInfo(id: String, info: Any) { val expirationMillis = MILLISECONDS.convert(1, HOURS) synchronized(LRU_CACHE) { val data = CacheData(info, expirationMillis) @@ -48,14 +51,33 @@ object InfoCache { synchronized(LRU_CACHE) { LRU_CACHE.remove(keyOf(id)) } } - fun clearCache() = synchronized(LRU_CACHE) { LRU_CACHE.evictAll() } + 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() } + 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 diff --git a/app/src/main/java/com/zionhuang/music/utils/KeyboardUtil.kt b/app/src/main/java/com/zionhuang/music/utils/KeyboardUtil.kt new file mode 100644 index 000000000..279c65621 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/utils/KeyboardUtil.kt @@ -0,0 +1,23 @@ +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 new file mode 100644 index 000000000..406b50dd9 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/utils/NavigationEndpointHandler.kt @@ -0,0 +1,122 @@ +package com.zionhuang.music.utils + +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.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 + + 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 { + 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 { + 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 { + SongRepository.addToPlaylist(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/NewPipeUtils.kt b/app/src/main/java/com/zionhuang/music/utils/NewPipeUtils.kt deleted file mode 100644 index 2e3d2b425..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/NewPipeUtils.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.zionhuang.music.utils - -import android.content.Context -import com.zionhuang.music.R -import com.zionhuang.music.extensions.sharedPreferences -import org.schabi.newpipe.extractor.localization.ContentCountry -import org.schabi.newpipe.extractor.localization.Localization -import java.util.* - -fun getPreferredLocalization(context: Context): Localization { - val systemDefault = context.getString(R.string.default_localization_key) - return context.sharedPreferences.getString(context.getString(R.string.pref_content_language), systemDefault)!!.let { - if (it == systemDefault) Localization.fromLocale(Locale.getDefault()) - else Localization.fromLocalizationCode(it) - } -} - -fun getPreferredContentCountry(context: Context): ContentCountry { - val systemDefault = context.getString(R.string.default_localization_key) - return context.sharedPreferences.getString(context.getString(R.string.pref_content_country), systemDefault)!!.let { - if (it == systemDefault) ContentCountry(Locale.getDefault().country) - else ContentCountry(it) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/OkHttpDownloader.kt b/app/src/main/java/com/zionhuang/music/utils/OkHttpDownloader.kt index 8133322f3..aa22042d2 100644 --- a/app/src/main/java/com/zionhuang/music/utils/OkHttpDownloader.kt +++ b/app/src/main/java/com/zionhuang/music/utils/OkHttpDownloader.kt @@ -6,10 +6,8 @@ import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import java.io.File object OkHttpDownloader { - private const val TAG = "OkHttpDownloader" private val client = OkHttpClient() @Throws(DownloadException::class) @@ -22,19 +20,5 @@ object OkHttpDownloader { } } - @Throws(DownloadException::class) - @Suppress("BlockingMethodInNonBlockingContext") - suspend fun downloadFile(url: String, destination: File) = withContext(IO) { - val request = Request.Builder().url(url).build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) throw DownloadException(response.message) - val inputStream = response.body!!.byteStream() - val outputStream = destination.outputStream() - inputStream.copyTo(outputStream) - inputStream.close() - outputStream.close() - } - } - 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/OkHttpGlideModule.kt b/app/src/main/java/com/zionhuang/music/utils/OkHttpGlideModule.kt deleted file mode 100644 index c6516248f..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/OkHttpGlideModule.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.zionhuang.music.utils - -import android.content.Context -import android.util.Log -import com.bumptech.glide.Glide -import com.bumptech.glide.GlideBuilder -import com.bumptech.glide.Registry -import com.bumptech.glide.annotation.GlideModule -import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader -import com.bumptech.glide.load.model.GlideUrl -import com.bumptech.glide.module.AppGlideModule -import okhttp3.OkHttpClient -import java.io.InputStream - -@GlideModule -class OkHttpGlideModule : AppGlideModule() { - override fun registerComponents(context: Context, glide: Glide, registry: Registry) { - val client = OkHttpClient.Builder().build() - val factory = OkHttpUrlLoader.Factory(client) - registry.append(GlideUrl::class.java, InputStream::class.java, factory) - } - - override fun applyOptions(context: Context, builder: GlideBuilder) { - builder.setLogLevel(Log.ERROR) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/SuggestionProvider.kt b/app/src/main/java/com/zionhuang/music/utils/SuggestionProvider.kt deleted file mode 100644 index 65baf32e8..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/SuggestionProvider.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.zionhuang.music.utils - -import android.content.SearchRecentSuggestionsProvider -import com.zionhuang.music.BuildConfig - -class SuggestionProvider : SearchRecentSuggestionsProvider() { - companion object { - const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.utils.SuggestionProvider" - const val MODE = DATABASE_MODE_QUERIES - } - - init { - setupSuggestions(AUTHORITY, MODE) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/utils/livedata/ThemeUtil.kt b/app/src/main/java/com/zionhuang/music/utils/ThemeUtil.kt similarity index 100% rename from app/src/main/java/com/zionhuang/music/utils/livedata/ThemeUtil.kt rename to app/src/main/java/com/zionhuang/music/utils/ThemeUtil.kt diff --git a/app/src/main/java/com/zionhuang/music/utils/Utils.kt b/app/src/main/java/com/zionhuang/music/utils/Utils.kt index f025d63c0..a040dedfc 100644 --- a/app/src/main/java/com/zionhuang/music/utils/Utils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/Utils.kt @@ -11,24 +11,16 @@ import java.math.BigInteger import java.security.MessageDigest import kotlin.system.measureTimeMillis -fun logTimeMillis(tag: String, msg: String, block: () -> T): T { - if (!BuildConfig.DEBUG) return block() - var result: T - val duration = measureTimeMillis { - result = block() - } - Log.d(tag, msg.format(duration)) - return result -} - fun md5(str: String): String { val md = MessageDigest.getInstance("MD5") return BigInteger(1, md.digest(str.toByteArray())).toString(16).padStart(32, '0') } -fun PagingDataAdapter.bindLoadStateLayout(binding: LayoutLoadStateBinding) { +fun List.joinByBullet() = filterNot { it.isNullOrEmpty() }.joinToString(separator = " • ") + +fun PagingDataAdapter.bindLoadStateLayout(binding: LayoutLoadStateBinding, isSwipeRefreshing: () -> Boolean = { false }) { addLoadStateListener { loadState -> - binding.progressBar.isVisible = loadState.refresh is LoadState.Loading + binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && !isSwipeRefreshing() binding.btnRetry.isVisible = loadState.refresh is LoadState.Error binding.errorMsg.isVisible = loadState.refresh is LoadState.Error if (loadState.refresh is LoadState.Error) { diff --git a/app/src/main/java/com/zionhuang/music/utils/YouTubeUtils.kt b/app/src/main/java/com/zionhuang/music/utils/YouTubeUtils.kt index 21bc0ac8e..74550e0f8 100644 --- a/app/src/main/java/com/zionhuang/music/utils/YouTubeUtils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/YouTubeUtils.kt @@ -6,13 +6,13 @@ private const val HMS_FORMAT = "%1\$d:%2$02d:%3$02d" /** * Convert duration in seconds to formatted time string * - * @param duration in seconds + * @param duration in milliseconds * @return formatted string */ fun makeTimeString(duration: Long?): String { if (duration == null) return "0:00" - var sec = duration + var sec = duration / 1000 val hour = sec / 3600 sec %= 3600 val minute = (sec / 60).toInt() 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 index 30c54227d..d414298e8 100644 --- a/app/src/main/java/com/zionhuang/music/utils/preference/Preference.kt +++ b/app/src/main/java/com/zionhuang/music/utils/preference/Preference.kt @@ -30,4 +30,11 @@ inline fun serializablePreference(context: Context, keyId: Int 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/viewmodels/ArtistsViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistsViewModel.kt deleted file mode 100644 index 1ae1ba643..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistsViewModel.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.app.Application -import android.content.Context -import androidx.core.os.bundleOf -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.zionhuang.music.constants.MediaConstants.EXTRA_ARTIST -import com.zionhuang.music.db.entities.ArtistEntity -import com.zionhuang.music.extensions.show -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.fragments.dialogs.EditArtistDialog -import com.zionhuang.music.ui.listeners.ArtistPopupMenuListener -import kotlinx.coroutines.launch - -class ArtistsViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository - - val popupMenuListener = object : ArtistPopupMenuListener { - override fun editArtist(artist: ArtistEntity, context: Context) { - EditArtistDialog().apply { - arguments = bundleOf(EXTRA_ARTIST to artist) - }.show(context) - } - - override fun deleteArtist(artist: ArtistEntity) { - viewModelScope.launch { - songRepository.deleteArtist(artist) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/ExploreViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/ExploreViewModel.kt deleted file mode 100644 index 05b1988a6..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ExploreViewModel.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.app.Application -import androidx.lifecycle.AndroidViewModel - -class ExploreViewModel(application: Application) : AndroidViewModel(application) { -// private val youTubeRepo: YouTubeRepository = getInstance(application) -// val flow = Pager(PagingConfig(pageSize = 20)) { -// YouTubeDataSource.Popular(youTubeRepo) -// }.flow.cachedIn(viewModelScope) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/LocalSearchViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/LocalSearchViewModel.kt new file mode 100644 index 000000000..948bfcf67 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LocalSearchViewModel.kt @@ -0,0 +1,39 @@ +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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest + +@OptIn(ExperimentalCoroutinesApi::class) +class LocalSearchViewModel(application: Application) : AndroidViewModel(application) { + val query = SafeMutableLiveData("") + val filter = SafeMutableLiveData(Filter.ALL) + val result: Flow> = query.asFlow().combine(filter.asFlow()) { query: String, filter: Filter -> + Pair(query, filter) + }.flatMapLatest { + val (query, filter) = it + if (query.isEmpty()) { + emptyFlow() + } 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) + } + } + } + + enum class Filter { + ALL, SONG, ALBUM, ARTIST, PLAYLIST + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt index a48a882ba..3456000c3 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt @@ -2,40 +2,29 @@ package com.zionhuang.music.viewmodels import android.app.Activity import android.app.Application -import android.os.Bundle import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat.* -import androidx.lifecycle.* -import com.google.android.exoplayer2.ui.PlayerView -import com.zionhuang.music.R -import com.zionhuang.music.extensions.preference -import com.zionhuang.music.models.MediaData +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.map import com.zionhuang.music.models.MediaSessionQueueData import com.zionhuang.music.models.PlaybackStateData -import com.zionhuang.music.models.toMediaData import com.zionhuang.music.playback.MediaSessionConnection +import com.zionhuang.music.playback.queues.Queue import com.zionhuang.music.ui.activities.MainActivity import com.zionhuang.music.utils.livedata.SafeLiveData import com.zionhuang.music.utils.livedata.SafeMutableLiveData class PlaybackViewModel(application: Application) : AndroidViewModel(application) { - private val _mediaData = MutableLiveData(null) - val mediaData: LiveData get() = _mediaData + val mediaMetadata: LiveData get() = MediaSessionConnection.nowPlaying private val _playbackState = SafeMutableLiveData(PlaybackStateData()) val playbackState: SafeLiveData get() = _playbackState - private val _queueData = SafeMutableLiveData(MediaSessionQueueData()) - val queueData: SafeLiveData get() = _queueData - - private val mediaMetadataObserver = Observer { mediaMetadata -> - if (mediaMetadata != null) { - _mediaData.postValue(mediaData.value?.pullMediaMetadata(mediaMetadata) - ?: mediaMetadata.toMediaData()) - } - } + val queueData: LiveData get() = MediaSessionConnection.queueData private val playbackStateObserver = Observer { playbackState -> if (playbackState != null) { @@ -43,31 +32,17 @@ class PlaybackViewModel(application: Application) : AndroidViewModel(application } } - private val queueDataObserver = Observer { queueData -> - _queueData.postValue(queueData) - } - private val mediaSessionConnection = MediaSessionConnection.apply { - connect(application) + if (!isConnected.value) connect(application) playbackState.observeForever(playbackStateObserver) - nowPlaying.observeForever(mediaMetadataObserver) - queueData.observeForever(queueDataObserver) } - val mediaSessionIsConnected = mediaSessionConnection.isConnected - - val mediaController: LiveData = mediaSessionIsConnected.map { isConnected -> + val mediaController: LiveData = mediaSessionConnection.isConnected.map { isConnected -> if (isConnected) mediaSessionConnection.mediaController else null } val transportControls: MediaControllerCompat.TransportControls? get() = mediaSessionConnection.transportControls - val expandOnPlay by preference(R.string.pref_expand_on_play, false) - - fun setPlayerView(playerView: PlayerView) { - mediaSessionConnection.playerView = playerView - } - fun togglePlayPause() { if (playbackState.value.state == STATE_PLAYING) { mediaSessionConnection.transportControls?.pause() @@ -95,19 +70,15 @@ class PlaybackViewModel(application: Application) : AndroidViewModel(application } } - fun playMedia(activity: Activity, mediaId: String, extras: Bundle) { - transportControls?.playFromMediaId(mediaId, extras) - if (expandOnPlay) { - (activity as? MainActivity)?.expandBottomSheet() - } + fun playQueue(activity: Activity, queue: Queue) { + mediaSessionConnection.binder?.songPlayer?.playQueue(queue) + (activity as? MainActivity)?.showBottomSheet() } override fun onCleared() { super.onCleared() mediaSessionConnection.apply { playbackState.removeObserver(playbackStateObserver) - nowPlaying.removeObserver(mediaMetadataObserver) - queueData.removeObserver(queueDataObserver) disconnect(getApplication()) } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/PlaylistSongsViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/PlaylistSongsViewModel.kt deleted file mode 100644 index 8a85f9a71..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/PlaylistSongsViewModel.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.zionhuang.music.extensions.swap -import com.zionhuang.music.repos.SongRepository -import kotlinx.coroutines.launch - -class PlaylistSongsViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository - - fun processMove(playlistId: Int, moves: List>) { - viewModelScope.launch { - val trackList = songRepository.getPlaylistSongEntities(playlistId).getList().toMutableList() - moves.forEach { (from, to) -> - trackList.swap(from, to) - } - songRepository.updatePlaylistSongEntities(trackList.mapIndexed { index, entity -> - entity.copy(idInPlaylist = index) - }) - } - } - - fun removeFromPlaylist(playlistId: Int, idInPlaylist: Int) { - viewModelScope.launch { - songRepository.removeSongFromPlaylist(playlistId, idInPlaylist) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/PlaylistsViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/PlaylistsViewModel.kt deleted file mode 100644 index b35c5eb06..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/PlaylistsViewModel.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.app.Application -import android.content.Context -import androidx.core.os.bundleOf -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.zionhuang.music.constants.MediaConstants.EXTRA_PLAYLIST -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.extensions.show -import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.ui.fragments.dialogs.EditPlaylistDialog -import com.zionhuang.music.ui.listeners.PlaylistPopupMenuListener -import kotlinx.coroutines.launch - -class PlaylistsViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository - - val popupMenuListener = object : PlaylistPopupMenuListener { - override fun editPlaylist(playlist: PlaylistEntity, context: Context) { - EditPlaylistDialog().apply { - arguments = bundleOf(EXTRA_PLAYLIST to playlist) - }.show(context) - } - - override fun deletePlaylist(playlist: PlaylistEntity) { - viewModelScope.launch { - songRepository.deletePlaylist(playlist) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/SearchViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/SearchViewModel.kt deleted file mode 100644 index f699a40ec..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/SearchViewModel.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.cachedIn -import com.zionhuang.music.repos.YouTubeRepository -import com.zionhuang.music.utils.livedata.SafeMutableLiveData -import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory.MUSIC_SONGS - -class SearchViewModel(application: Application) : AndroidViewModel(application) { - var searchFilter = SafeMutableLiveData(MUSIC_SONGS) - - fun search(query: String) = Pager(PagingConfig(pageSize = 20)) { - YouTubeRepository.search(query, searchFilter.value) - }.flow.cachedIn(viewModelScope) -} \ 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 index 55a504b57..7fd0c32b0 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt @@ -1,192 +1,58 @@ package com.zionhuang.music.viewmodels import android.app.Application -import android.content.Context -import androidx.core.os.bundleOf import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.switchMap -import androidx.lifecycle.viewModelScope -import androidx.paging.* -import androidx.paging.TerminalSeparatorType.FULLY_COMPLETE -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.HEADER_PLACEHOLDER_SONG -import com.zionhuang.music.constants.MediaConstants.EXTRA_SONG -import com.zionhuang.music.constants.MediaConstants.EXTRA_SONGS -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.ArtistEntity -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.extensions.show -import com.zionhuang.music.extensions.toSong -import com.zionhuang.music.models.DownloadProgress -import com.zionhuang.music.models.PreferenceSortInfo -import com.zionhuang.music.models.base.IMutableSortInfo -import com.zionhuang.music.models.toMediaData +import com.zionhuang.innertube.utils.plus +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.playback.MediaSessionConnection import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.repos.base.LocalRepository -import com.zionhuang.music.ui.fragments.dialogs.EditSongDialog -import com.zionhuang.music.ui.listeners.SongPopupMenuListener -import com.zionhuang.music.ui.listeners.StreamPopupMenuListener -import com.zionhuang.music.utils.DownloadProgressMapLiveData -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import org.schabi.newpipe.extractor.stream.StreamInfoItem +@OptIn(ExperimentalCoroutinesApi::class) class SongsViewModel(application: Application) : AndroidViewModel(application) { - val songRepository: LocalRepository = SongRepository + val songRepository = SongRepository val mediaSessionConnection = MediaSessionConnection - val sortInfo: IMutableSortInfo = PreferenceSortInfo - var query: String? = null - val allSongsFlow: Flow> by lazy { - Pager(PagingConfig(pageSize = 50, enablePlaceholders = true)) { - if (!query.isNullOrBlank()) { - songRepository.searchSongs(query!!).pagingSource - } else { - songRepository.getAllSongs(sortInfo).pagingSource - } - }.flow.map { pagingData -> - if (query.isNullOrBlank()) pagingData.insertHeaderItem(FULLY_COMPLETE, HEADER_PLACEHOLDER_SONG) - else pagingData - }.cachedIn(viewModelScope) + val allSongsFlow = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> + SongRepository.getAllSongs(sortInfo).flow + }.map { list -> + SongHeader(SongRepository.getSongCount(), SongSortInfoPreference.currentInfo) + list } - val allArtistsFlow: Flow> by lazy { - Pager(PagingConfig(pageSize = 50)) { - songRepository.getAllArtists().pagingSource - }.flow.cachedIn(viewModelScope) + val allArtistsFlow = ArtistSortInfoPreference.flow.flatMapLatest { sortInfo -> + SongRepository.getAllArtists(sortInfo).flow + }.map { list -> + ArtistHeader(SongRepository.getArtistCount(), ArtistSortInfoPreference.currentInfo) + list } - val allPlaylistsFlow: Flow> by lazy { - Pager(PagingConfig(pageSize = 50)) { - songRepository.getAllPlaylists().pagingSource - }.flow.cachedIn(viewModelScope) + val allAlbumsFlow = AlbumSortInfoPreference.flow.flatMapLatest { sortInfo -> + SongRepository.getAllAlbums(sortInfo).flow + }.map { list -> + AlbumHeader(SongRepository.getAlbumCount(), AlbumSortInfoPreference.currentInfo) + list } - fun getArtistSongsAsFlow(artistId: Int) = Pager(PagingConfig(pageSize = 50)) { - songRepository.getArtistSongs(artistId, sortInfo).pagingSource - }.flow.map { pagingData -> - pagingData.insertHeaderItem(FULLY_COMPLETE, HEADER_PLACEHOLDER_SONG) - }.cachedIn(viewModelScope) - - fun getPlaylistSongsAsFlow(playlistId: Int) = Pager(PagingConfig(pageSize = 50)) { - songRepository.getPlaylistSongs(playlistId, sortInfo).pagingSource - }.flow.cachedIn(viewModelScope) - - val songPopupMenuListener = object : SongPopupMenuListener { - override fun editSong(song: Song, context: Context) { - EditSongDialog().apply { - arguments = bundleOf(EXTRA_SONG to song) - }.show(context) - } - - override fun playNext(songs: List, context: Context) { - mediaSessionConnection.mediaController?.sendCommand( - COMMAND_PLAY_NEXT, - bundleOf(EXTRA_SONGS to songs.map { it.toMediaData(context) }.toTypedArray()), - null - ) - } - - override fun addToQueue(songs: List, context: Context) { - mediaSessionConnection.mediaController?.sendCommand( - COMMAND_ADD_TO_QUEUE, - bundleOf(EXTRA_SONGS to songs.map { it.toMediaData(context) }.toTypedArray()), - null - ) - } - - override fun addToPlaylist(songs: List, context: Context) { - viewModelScope.launch { - val playlists = songRepository.getAllPlaylists().getList() - MaterialAlertDialogBuilder(context) - .setTitle(R.string.dialog_title_choose_playlist) - .setItems(playlists.map { it.name }.toTypedArray()) { _, i -> - viewModelScope.launch { - songRepository.addSongsToPlaylist(playlists[i].playlistId, songs) - } - } - .show() - } - } - - override fun downloadSongs(songIds: List, context: Context) { - viewModelScope.launch { - songRepository.downloadSongs(songIds) - } - } - - override fun removeDownloads(songIds: List, context: Context) { - viewModelScope.launch { - songRepository.removeDownloads(songIds) - } - } - - override fun deleteSongs(songs: List) { - viewModelScope.launch { - songRepository.deleteSongs(songs) - } - } + val allPlaylistsFlow = PlaylistSortInfoPreference.flow.flatMapLatest { sortInfo -> + SongRepository.getAllPlaylists(sortInfo).flow + }.map { list -> + PlaylistHeader(SongRepository.getPlaylistCount(), PlaylistSortInfoPreference.currentInfo) + list } - val streamPopupMenuListener = object : StreamPopupMenuListener { - override fun addToLibrary(songs: List) { - viewModelScope.launch { - songRepository.addSongs(songs.map(StreamInfoItem::toSong)) - } - } - - override fun playNext(songs: List) { - mediaSessionConnection.mediaController?.sendCommand( - COMMAND_PLAY_NEXT, - bundleOf(EXTRA_SONGS to songs.map(StreamInfoItem::toMediaData).toTypedArray()), - null - ) - } - - override fun addToQueue(songs: List) { - mediaSessionConnection.mediaController?.sendCommand( - COMMAND_ADD_TO_QUEUE, - bundleOf(EXTRA_SONGS to songs.map(StreamInfoItem::toMediaData).toTypedArray()), - null - ) - } - - override fun addToPlaylist(songs: List, context: Context) { - viewModelScope.launch { - val playlists = songRepository.getAllPlaylists().getList() - MaterialAlertDialogBuilder(context) - .setTitle(R.string.dialog_title_choose_playlist) - .setItems(playlists.map { it.name }.toTypedArray()) { _, i -> - viewModelScope.launch { - songs.map(StreamInfoItem::toSong).let { - songRepository.addSongs(it) - songRepository.addSongsToPlaylist(playlists[i].playlistId, it) - } - } - } - .show() - } - } - - override fun download(songs: List, context: Context) { - viewModelScope.launch { - songs.map(StreamInfoItem::toSong).let { s -> - songRepository.addSongs(s) - songRepository.downloadSongs(s.map { it.id }) - } - } - } + @Suppress("UNCHECKED_CAST") + fun getArtistSongsAsFlow(artistId: String) = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> + SongRepository.getArtistSongs(artistId, sortInfo).flow + }.map { list -> + SongHeader(SongRepository.getArtistSongCount(artistId), SongSortInfoPreference.currentInfo) + list } - val downloadInfoLiveData: LiveData> = songRepository.getAllDownloads().liveData.switchMap { - DownloadProgressMapLiveData(application, it) + fun getPlaylistSongsAsFlow(playlistId: String) = songRepository.getPlaylistSongs(playlistId).flow.map { list -> + PlaylistSongHeader(list.size, list.sumOf { it.song.duration.toLong() }) + list } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt index cf8c61515..1ddf3f4b9 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt @@ -4,30 +4,25 @@ 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) { - val onFillQuery = MutableLiveData() - val query = MutableLiveData(null) - val suggestions = MutableLiveData>(emptyList()) + val suggestions = MutableLiveData>(emptyList()) - fun fillQuery(q: String) { - onFillQuery.postValue(q) - } - - fun setQuery(q: String?) { - query.postValue(q) - } - - fun fetchSuggestions(query: String?) { + fun fetchSuggestions(query: String?) = viewModelScope.launch { if (query.isNullOrEmpty()) { - suggestions.postValue(emptyList()) - return - } - viewModelScope.launch { + suggestions.postValue(SongRepository.getAllSearchHistory().map { SuggestionTextItem(it.query, LOCAL) }) + } else { try { - suggestions.postValue(YouTubeRepository.suggestionsFor(query)) + val history = SongRepository.getSearchHistory(query).map { SuggestionTextItem(it.query, LOCAL) } + suggestions.postValue(history + YouTubeRepository.getSuggestions(query).filter { item -> + item !is SuggestionTextItem || history.find { it.query == item.query } == null + }) } catch (e: Exception) { } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt new file mode 100644 index 000000000..6ce745f7f --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt @@ -0,0 +1,50 @@ +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.BrowseEndpoint +import com.zionhuang.innertube.models.NavigationEndpoint +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.YTBaseItem +import com.zionhuang.music.repos.YouTubeRepository + +class YouTubeBrowseViewModel(application: Application, private val browseEndpoint: BrowseEndpoint) : AndroidViewModel(application) { + private var albumSongs: List? = null + fun getAlbumSongs() = albumSongs + + val pagingData = Pager(PagingConfig(pageSize = 20)) { + if (browseEndpoint.isAlbumEndpoint) { + object : PagingSource, YTBaseItem>() { + override suspend fun load(params: LoadParams>): LoadResult, YTBaseItem> = LoadResult.Page( + data = YouTube.browse(browseEndpoint).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 + ) + + 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/YouTubeChannelViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeChannelViewModel.kt deleted file mode 100644 index 86fa0870e..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeChannelViewModel.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.cachedIn -import com.zionhuang.music.repos.YouTubeRepository -import com.zionhuang.music.youtube.NewPipeYouTubeHelper - -class YouTubeChannelViewModel(application: Application) : AndroidViewModel(application) { - suspend fun getChannelInfo(channelId: String) = NewPipeYouTubeHelper.getChannel(channelId) - - fun getChannel(channelId: String) = Pager(PagingConfig(pageSize = 20)) { - YouTubeRepository.getChannel(channelId) - }.flow.cachedIn(viewModelScope) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubePlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubePlaylistViewModel.kt deleted file mode 100644 index c9442ae42..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubePlaylistViewModel.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.cachedIn -import com.zionhuang.music.repos.YouTubeRepository -import com.zionhuang.music.youtube.NewPipeYouTubeHelper - -class YouTubePlaylistViewModel(application: Application) : AndroidViewModel(application) { - suspend fun getPlaylistInfo(playlistId: String) = NewPipeYouTubeHelper.getPlaylist(playlistId) - - fun getPlaylist(playlistId: String) = Pager(PagingConfig(pageSize = 20)) { - YouTubeRepository.getPlaylist(playlistId) - }.flow.cachedIn(viewModelScope) -} \ 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 new file mode 100644 index 000000000..68fb6fb95 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeSearchViewModel.kt @@ -0,0 +1,27 @@ +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) { + 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/java/com/zionhuang/music/youtube/NewPipeDownloader.kt b/app/src/main/java/com/zionhuang/music/youtube/NewPipeDownloader.kt deleted file mode 100644 index a6271cf4d..000000000 --- a/app/src/main/java/com/zionhuang/music/youtube/NewPipeDownloader.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.zionhuang.music.youtube - -import okhttp3.OkHttpClient -import okhttp3.RequestBody.Companion.toRequestBody -import org.schabi.newpipe.extractor.downloader.Downloader -import org.schabi.newpipe.extractor.downloader.Request -import org.schabi.newpipe.extractor.downloader.Response -import org.schabi.newpipe.extractor.exceptions.ReCaptchaException -import java.io.IOException -import java.util.concurrent.TimeUnit - -class NewPipeDownloader private constructor(builder: OkHttpClient.Builder) : Downloader() { - private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build() - - @Throws(IOException::class, ReCaptchaException::class) - override fun execute(request: Request): Response { - val httpMethod = request.httpMethod() - val url = request.url() - val headers = request.headers() - val dataToSend = request.dataToSend() - - val requestBody = dataToSend?.toRequestBody(null, 0) - - val requestBuilder = okhttp3.Request.Builder() - .method(httpMethod, requestBody).url(url) - .addHeader("User-Agent", USER_AGENT) - - headers.forEach { (headerName, headerValueList) -> - if (headerValueList.size > 1) { - requestBuilder.removeHeader(headerName) - headerValueList.forEach { headerValue -> - requestBuilder.addHeader(headerName, headerValue) - } - } else if (headerValueList.size == 1) { - requestBuilder.header(headerName, headerValueList[0]) - } - } - - val response = client.newCall(requestBuilder.build()).execute() - - if (response.code == 429) { - response.close() - throw ReCaptchaException("reCaptcha Challenge requested", url) - } - - val body = response.body - val responseBodyToReturn: String? = body?.string() - - val latestUrl = response.request.url.toString() - return Response(response.code, response.message, response.headers.toMultimap(), responseBodyToReturn, latestUrl) - } - - companion object { - private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:68.0) Gecko/20100101 Firefox/68.0" - - private var instance: NewPipeDownloader? = null - - /** - * It's recommended to call exactly once in the entire lifetime of the application. - * - * @param builder if null, default builder will be used - * @return a new instance of [NewPipeDownloader] - */ - fun init(builder: OkHttpClient.Builder? = null): NewPipeDownloader { - instance = NewPipeDownloader(builder ?: OkHttpClient.Builder()) - return instance!! - } - - fun getInstance(): NewPipeDownloader? { - if (instance == null) { - init(null) - } - return instance - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/youtube/NewPipeYouTubeHelper.kt b/app/src/main/java/com/zionhuang/music/youtube/NewPipeYouTubeHelper.kt deleted file mode 100644 index b48206eb0..000000000 --- a/app/src/main/java/com/zionhuang/music/youtube/NewPipeYouTubeHelper.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.zionhuang.music.youtube - -import com.zionhuang.music.extensions.tryOrNull -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.withContext -import org.schabi.newpipe.extractor.* -import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage -import org.schabi.newpipe.extractor.channel.ChannelInfo -import org.schabi.newpipe.extractor.exceptions.ExtractionException -import org.schabi.newpipe.extractor.playlist.PlaylistInfo -import org.schabi.newpipe.extractor.search.SearchInfo -import org.schabi.newpipe.extractor.services.youtube.YoutubeService -import org.schabi.newpipe.extractor.stream.StreamInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem -import java.io.IOException - -@Suppress("BlockingMethodInNonBlockingContext") -object NewPipeYouTubeHelper { - private val service = NewPipe.getService(ServiceList.YouTube.serviceId) as YoutubeService - - fun getLinkType(url: String): StreamingService.LinkType = service.getLinkTypeByUrl(url) - - /** - * Stream - */ - fun extractVideoId(url: String): String? = tryOrNull { service.streamLHFactory.getId(url) } - - fun videoIdToUrl(id: String): String? = tryOrNull { service.streamLHFactory.getUrl(id) } - - /** - * Search - */ - suspend fun search(query: String, contentFilter: List): SearchInfo = checkCache("${query}$${contentFilter[0]}") { - SearchInfo.getInfo(service, service.searchQHFactory.fromQuery(query, contentFilter, "")) - } - - suspend fun search(query: String, contentFilter: List, page: Page): InfoItemsPage = checkCache("${query}$${contentFilter[0]}$${page.hashCode()}") { - SearchInfo.getMoreItems(service, service.searchQHFactory.fromQuery(query, contentFilter, ""), page) - } - - suspend fun suggestionsFor(query: String): List = withContext(IO) { - service.suggestionExtractor.suggestionList(query) - } - - /** - * Playlist - */ - fun extractPlaylistId(url: String): String? = tryOrNull { service.playlistLHFactory.getId(url) } - - suspend fun getPlaylist(id: String): PlaylistInfo = checkCache(id) { - PlaylistInfo.getInfo(service, service.playlistLHFactory.getUrl(id)) - } - - suspend fun getPlaylist(id: String, page: Page): InfoItemsPage = checkCache("$id$${page.hashCode()}") { - PlaylistInfo.getMoreItems(service, service.playlistLHFactory.getUrl(id), page) - } - - /** - * Channel - */ - fun extractChannelId(url: String): String? = tryOrNull { service.channelLHFactory.getId(url) } - - suspend fun getChannel(id: String): ChannelInfo = checkCache(id) { - ChannelInfo.getInfo(service, service.channelLHFactory.getUrl(id)) - } - - suspend fun getChannel(id: String, page: Page): InfoItemsPage = checkCache("$id$${page.hashCode()}") { - ChannelInfo.getMoreItems(service, service.channelLHFactory.getUrl(id), page) - } - - @Throws(IOException::class, ExtractionException::class) - suspend fun getStreamInfo(id: String): StreamInfo = checkCache("stream$$id") { - StreamInfo.getInfo(service, service.streamLHFactory.getUrl(id)) - } - - private suspend fun checkCache(id: String, loadFromNetwork: suspend () -> T): T = - loadFromCache(id) ?: withContext(IO) { - loadFromNetwork().also { - InfoCache.putInfo(id, it) - } - } - - @Suppress("UNCHECKED_CAST") - private fun loadFromCache(id: String): T? = - InfoCache.getFromKey(id) as T? -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/youtube/StreamHelper.kt b/app/src/main/java/com/zionhuang/music/youtube/StreamHelper.kt deleted file mode 100644 index 00efc5c89..000000000 --- a/app/src/main/java/com/zionhuang/music/youtube/StreamHelper.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.zionhuang.music.youtube - -import org.schabi.newpipe.extractor.MediaFormat -import org.schabi.newpipe.extractor.MediaFormat.* -import org.schabi.newpipe.extractor.stream.AudioStream - -object StreamHelper { - // Audio format in order of quality. 0=lowest quality, n=highest quality - private val AUDIO_FORMAT_QUALITY_RANKING = listOf(MP3, WEBMA, M4A) - // Audio format in order of efficiency. 0=least efficient, n=most efficient - private val AUDIO_FORMAT_EFFICIENCY_RANKING = listOf(MP3, M4A, WEBMA) - - private fun compareAudioStreamBitrate(streamA: AudioStream, streamB: AudioStream, formatRanking: List) = if (streamA.averageBitrate != streamB.averageBitrate) - streamA.averageBitrate - streamB.averageBitrate - else formatRanking.indexOf(streamB.getFormat()) - formatRanking.indexOf(streamB.getFormat()) - - fun getHighestQualityAudioStream(audioStreams: List): AudioStream? = audioStreams.sortedWith { a, b -> - compareAudioStreamBitrate(a, b, AUDIO_FORMAT_QUALITY_RANKING) - }.lastOrNull() - - fun getMostCompactAudioStream(audioStreams: List): AudioStream? = audioStreams.sortedWith { a, b -> - compareAudioStreamBitrate(a, b, AUDIO_FORMAT_EFFICIENCY_RANKING) - }.firstOrNull() - -} \ No newline at end of file diff --git a/app/src/main/res/color/bottom_navigation_colors.xml b/app/src/main/res/color/bottom_navigation_colors.xml deleted file mode 100755 index 168e692b2..000000000 --- a/app/src/main/res/color/bottom_navigation_colors.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/avd_arrow_down_to_up.xml b/app/src/main/res/drawable/avd_arrow_down_to_up.xml index 2f0fbdffb..0e108e8bf 100644 --- a/app/src/main/res/drawable/avd_arrow_down_to_up.xml +++ b/app/src/main/res/drawable/avd_arrow_down_to_up.xml @@ -5,6 +5,7 @@ android:name="arrow_down_to_up" android:width="24dp" android:height="24dp" + android:tint="?colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> + android:fillColor="@android:color/white" + android:pathData="M 20 12 L 18.59 10.59 L 13 16.17 L 13 4 L 11 4 L 11 16.17 L 5.42 10.58 L 4 12 L 12 20 L 20 12 Z" /> + android:valueType="floatType" /> 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 index 16821ac42..5131fe276 100644 --- a/app/src/main/res/drawable/avd_arrow_up_to_down.xml +++ b/app/src/main/res/drawable/avd_arrow_up_to_down.xml @@ -5,6 +5,7 @@ android:name="arrow_up_to_down" android:width="24dp" android:height="24dp" + android:tint="?colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> + android:fillColor="@android:color/white" + android:pathData="M 4 12 L 5.41 13.41 L 11 7.83 L 11 20 L 13 20 L 13 7.83 L 18.58 13.42 L 20 12 L 12 4 L 4 12 Z" /> + android:valueType="floatType" /> diff --git a/app/src/main/res/drawable/btn_play_pause_background.xml b/app/src/main/res/drawable/btn_play_pause_background.xml new file mode 100644 index 000000000..2119fa885 --- /dev/null +++ b/app/src/main/res/drawable/btn_play_pause_background.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_album.xml b/app/src/main/res/drawable/ic_album.xml new file mode 100644 index 000000000..6fefe0c60 --- /dev/null +++ b/app/src/main/res/drawable/ic_album.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_downward.xml b/app/src/main/res/drawable/ic_arrow_downward.xml index cbb5139ae..6ce6a15a7 100644 --- a/app/src/main/res/drawable/ic_arrow_downward.xml +++ b/app/src/main/res/drawable/ic_arrow_downward.xml @@ -2,8 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:tint="?colorControlNormal" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_arrow_top_left.xml b/app/src/main/res/drawable/ic_arrow_top_left.xml index ff23269f9..20b455730 100644 --- a/app/src/main/res/drawable/ic_arrow_top_left.xml +++ b/app/src/main/res/drawable/ic_arrow_top_left.xml @@ -2,6 +2,7 @@ + android:tint="?colorControlNormal"> diff --git a/app/src/main/res/drawable/ic_artist.xml b/app/src/main/res/drawable/ic_artist.xml index 8de858fc2..c7f3087c0 100644 --- a/app/src/main/res/drawable/ic_artist.xml +++ b/app/src/main/res/drawable/ic_artist.xml @@ -2,7 +2,7 @@ + + diff --git a/app/src/main/res/drawable/ic_dark_mode.xml b/app/src/main/res/drawable/ic_dark_mode.xml new file mode 100644 index 000000000..e52c5baff --- /dev/null +++ b/app/src/main/res/drawable/ic_dark_mode.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_equalizer.xml b/app/src/main/res/drawable/ic_equalizer.xml new file mode 100644 index 000000000..ebb2e42c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_equalizer.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_explore.xml b/app/src/main/res/drawable/ic_explore.xml index 68aa81479..802944f85 100644 --- a/app/src/main/res/drawable/ic_explore.xml +++ b/app/src/main/res/drawable/ic_explore.xml @@ -1,9 +1,10 @@ + android:width="24dp" + android:height="24dp" + android:tint="?colorControlNormal" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + android:fillColor="@android:color/white" + android:pathData="M12,10.9c-0.61,0 -1.1,0.49 -1.1,1.1s0.49,1.1 1.1,1.1c0.61,0 1.1,-0.49 1.1,-1.1s-0.49,-1.1 -1.1,-1.1zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM14.19,14.19L6,18l3.81,-8.19L18,6l-3.81,8.19z" /> diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml index a61de1bc9..5224228b8 100644 --- a/app/src/main/res/drawable/ic_history.xml +++ b/app/src/main/res/drawable/ic_history.xml @@ -1,9 +1,10 @@ + android:width="24dp" + android:height="24dp" + android:tint="?colorControlNormal" + android:viewportWidth="24" + android:viewportHeight="24"> + android:fillColor="@android:color/white" + android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z" /> diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml index f8bb0b556..3b2c4ccb4 100644 --- a/app/src/main/res/drawable/ic_home.xml +++ b/app/src/main/res/drawable/ic_home.xml @@ -1,8 +1,9 @@ + android:tint="?attr/colorControlNormal" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_input.xml b/app/src/main/res/drawable/ic_input.xml new file mode 100644 index 000000000..2f7eecaf0 --- /dev/null +++ b/app/src/main/res/drawable/ic_input.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard_voice.xml b/app/src/main/res/drawable/ic_keyboard_voice.xml new file mode 100644 index 000000000..a1c5ba04f --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_voice.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_library_add_check.xml b/app/src/main/res/drawable/ic_library_add_check.xml index 7ad54a07b..645994b97 100644 --- a/app/src/main/res/drawable/ic_library_add_check.xml +++ b/app/src/main/res/drawable/ic_library_add_check.xml @@ -1,6 +1,6 @@ diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml index 67f57bcdf..7847a372d 100644 --- a/app/src/main/res/drawable/ic_more_vert.xml +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -1,6 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_new_releases.xml b/app/src/main/res/drawable/ic_new_releases.xml new file mode 100644 index 000000000..1f74b4aef --- /dev/null +++ b/app/src/main/res/drawable/ic_new_releases.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_newpipe.xml b/app/src/main/res/drawable/ic_newpipe.xml deleted file mode 100644 index ba78c0b0b..000000000 --- a/app/src/main/res/drawable/ic_newpipe.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 new file mode 100644 index 000000000..52011771f --- /dev/null +++ b/app/src/main/res/drawable/ic_palette.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_queue_music.xml b/app/src/main/res/drawable/ic_queue_music.xml index 64bbf98c7..4240383fc 100644 --- a/app/src/main/res/drawable/ic_queue_music.xml +++ b/app/src/main/res/drawable/ic_queue_music.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 000000000..f15525471 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_sentiment_satisfied.xml b/app/src/main/res/drawable/ic_sentiment_satisfied.xml new file mode 100644 index 000000000..1d5c49c09 --- /dev/null +++ b/app/src/main/res/drawable/ic_sentiment_satisfied.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml index 136314dff..48f6a8490 100644 --- a/app/src/main/res/drawable/ic_share.xml +++ b/app/src/main/res/drawable/ic_share.xml @@ -1,9 +1,10 @@ diff --git a/app/src/main/res/drawable/ic_shuffle.xml b/app/src/main/res/drawable/ic_shuffle.xml index 1532e77dd..41876a2bd 100644 --- a/app/src/main/res/drawable/ic_shuffle.xml +++ b/app/src/main/res/drawable/ic_shuffle.xml @@ -1,6 +1,7 @@ + + diff --git a/app/src/main/res/drawable/background_song_cover.xml b/app/src/main/res/drawable/round_background.xml similarity index 55% rename from app/src/main/res/drawable/background_song_cover.xml rename to app/src/main/res/drawable/round_background.xml index c46718d8c..68c8be17f 100644 --- a/app/src/main/res/drawable/background_song_cover.xml +++ b/app/src/main/res/drawable/round_background.xml @@ -1,6 +1,5 @@ - - + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index d390f0aea..ab92cdd65 100755 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -19,33 +19,13 @@ android:layout_marginBottom="@dimen/m3_bottom_nav_min_height" android:fitsSystemWindows="true"> - - - - - - + app:navGraph="@navigation/main_navigation_graph" /> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/bottom_controls_sheet.xml b/app/src/main/res/layout/bottom_controls_sheet.xml index e10f33406..3e1dc043e 100644 --- a/app/src/main/res/layout/bottom_controls_sheet.xml +++ b/app/src/main/res/layout/bottom_controls_sheet.xml @@ -7,6 +7,10 @@ + + @@ -113,7 +117,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:text="@{viewModel.mediaData.title}" + android:text="@{viewModel.mediaMetadata.getText(MediaMetadata.METADATA_KEY_TITLE)}" android:textColor="?android:textColorPrimary" android:textSize="16sp" tools:text="Song Title" /> @@ -126,7 +130,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:text="@{viewModel.mediaData.artist}" + android:text="@{viewModel.mediaMetadata.getText(MediaMetadata.METADATA_KEY_ARTIST)}" android:textColor="?android:textColorSecondary" android:textSize="12sp" tools:text="Song Artist" /> @@ -139,11 +143,13 @@ android:layout_gravity="center_vertical" android:layout_marginStart="10dp" android:layout_marginEnd="10dp" + android:background="?selectableItemBackgroundBorderless" android:onClick="@{()->viewModel.togglePlayPause()}" android:padding="4dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@id/btn_btm_skip_next" app:layout_constraintTop_toTopOf="parent" + app:playPauseButtonTint="?invertedColor" app:playState="@{viewModel.playbackState.state}" tools:src="@drawable/ic_pause" /> @@ -170,20 +176,11 @@ - - + android:layout_height="match_parent" /> @@ -241,7 +238,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:text="@{viewModel.mediaData.artist}" + android:text="@{viewModel.mediaMetadata.getText(MediaMetadata.METADATA_KEY_ARTIST)}" android:textAlignment="center" android:textColor="?android:textColorSecondary" tools:text="Song Artist" /> @@ -303,10 +300,12 @@ android:layout_height="wrap_content" android:valueFrom="0" android:valueTo="100" + app:haloRadius="12dp" app:labelBehavior="gone" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" + app:thumbRadius="6dp" tools:value="50" tools:valueFrom="0" tools:valueTo="100" /> @@ -321,7 +320,7 @@ diff --git a/app/src/main/res/layout/dialog_choose_playlist.xml b/app/src/main/res/layout/dialog_choose_playlist.xml new file mode 100644 index 000000000..a0f828f86 --- /dev/null +++ b/app/src/main/res/layout/dialog_choose_playlist.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/edit_song_dialog.xml b/app/src/main/res/layout/dialog_edit_song.xml similarity index 97% rename from app/src/main/res/layout/edit_song_dialog.xml rename to app/src/main/res/layout/dialog_edit_song.xml index c2bb5f178..2cc98bf7b 100644 --- a/app/src/main/res/layout/edit_song_dialog.xml +++ b/app/src/main/res/layout/dialog_edit_song.xml @@ -15,7 +15,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" - android:background="@drawable/background_song_cover" android:minWidth="50dp" android:minHeight="50dp" android:scaleType="centerCrop" @@ -50,6 +49,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:completionThreshold="1" + android:enabled="false" android:singleLine="true" tools:text="Song Artist" /> diff --git a/app/src/main/res/layout/fragment_explore.xml b/app/src/main/res/layout/fragment_explore.xml deleted file mode 100644 index 420f4cd47..000000000 --- a/app/src/main/res/layout/fragment_explore.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_playlists.xml b/app/src/main/res/layout/fragment_playlists.xml deleted file mode 100644 index bdadbdf28..000000000 --- a/app/src/main/res/layout/fragment_playlists.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index 899036fca..3382f47a8 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -1,96 +1,130 @@ - + android:layout_height="match_parent"> - - - + + + + + + + + - - + android:scrollbars="none" + app:layout_constraintBottom_toTopOf="@id/swipe_refresh" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> - + android:paddingStart="8dp" + android:paddingEnd="8dp" + app:selectionRequired="true" + app:singleLine="true" + app:singleSelection="true"> - + - + - + - + - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_search_local.xml b/app/src/main/res/layout/fragment_search_local.xml new file mode 100644 index 000000000..ae242fb9d --- /dev/null +++ b/app/src/main/res/layout/fragment_search_local.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_song.xml b/app/src/main/res/layout/fragment_song.xml new file mode 100644 index 000000000..1d818a82a --- /dev/null +++ b/app/src/main/res/layout/fragment_song.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_update.xml b/app/src/main/res/layout/fragment_update.xml index 14e788cb8..58049f03d 100644 --- a/app/src/main/res/layout/fragment_update.xml +++ b/app/src/main/res/layout/fragment_update.xml @@ -6,9 +6,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" - android:padding="18dp" - tools:layout_editor_absoluteX="1dp" - tools:layout_editor_absoluteY="120dp"> + android:padding="16dp"> + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_album.xml b/app/src/main/res/layout/item_album.xml new file mode 100644 index 000000000..22aede28e --- /dev/null +++ b/app/src/main/res/layout/item_album.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_artist.xml b/app/src/main/res/layout/item_artist.xml index cf7d904fa..49865566c 100644 --- a/app/src/main/res/layout/item_artist.xml +++ b/app/src/main/res/layout/item_artist.xml @@ -7,38 +7,50 @@ + type="com.zionhuang.music.db.entities.Artist" /> + android:paddingStart="6dp" + android:paddingEnd="6dp"> - + + - - - + app:layout_constraintTop_toTopOf="parent" + tools:src="@drawable/ic_artist" /> + tools:text="Artist" /> + + - \ No newline at end of file diff --git a/app/src/main/res/layout/item_channel_header.xml b/app/src/main/res/layout/item_channel_header.xml deleted file mode 100644 index 6a7c43abb..000000000 --- a/app/src/main/res/layout/item_channel_header.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - -