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